diff --git a/open_wearable/docs/pages/fota-pages.md b/open_wearable/docs/pages/fota-pages.md index a9036ee1..30a5e04b 100644 --- a/open_wearable/docs/pages/fota-pages.md +++ b/open_wearable/docs/pages/fota-pages.md @@ -35,6 +35,17 @@ - Provides: - Detailed progress and outcome UI for the update process. +## `FotaSlotsPage` (`lib/widgets/fota/fota_slots_page.dart`) +- Needs: + - Constructor input: wearable with `FotaSlotInfoCapability`. +- Does: + - Reads and groups reported MCUboot image slots by image index. + - Shows active, confirmed, pending, permanent, bootable, version, and hash metadata for each slot. + - Lets users confirm and erase eligible inactive secondary slots through `eraseFirmwareSlot`. + - Keeps protected slots read-only and offers mcumgr web as a fallback recovery tool. +- Provides: + - Firmware slot inspection and recovery controls for stuck FOTA states. + ## `LoggerScreen` (`lib/widgets/fota/logger_screen/logger_screen.dart`) - Needs: - Constructor input: `FirmwareUpdateLogger logger`. diff --git a/open_wearable/lib/widgets/fota/fota_slots_page.dart b/open_wearable/lib/widgets/fota/fota_slots_page.dart index cf21fbc7..21f715ee 100644 --- a/open_wearable/lib/widgets/fota/fota_slots_page.dart +++ b/open_wearable/lib/widgets/fota/fota_slots_page.dart @@ -25,6 +25,7 @@ class _FotaSlotsPageState extends State { Uri.parse('https://boogie.github.io/mcumgr-web/'); late Future> _slotFuture; + String? _erasingSlotKey; @override void initState() { @@ -60,6 +61,101 @@ class _FotaSlotsPageState extends State { await future; } + /// Confirms and erases the secondary firmware slot represented by [slot]. + Future _eraseFirmwareSlot(FirmwareSlotInfo slot) async { + if (!_canEraseSlot(slot) || + _erasingSlotKey != null || + !widget.device.hasCapability()) { + return; + } + + final confirmed = await _confirmEraseSlot(slot); + if (!confirmed || !mounted) { + return; + } + + final slotKey = _slotKey(slot); + setState(() { + _erasingSlotKey = slotKey; + }); + + try { + final capability = + widget.device.requireCapability(); + await capability.eraseFirmwareSlot(channel: _eraseChannelFor(slot)); + + if (!mounted) { + return; + } + + AppToast.show( + context, + message: 'Firmware slot ${slot.slot} erased.', + type: AppToastType.success, + icon: Icons.delete_sweep_rounded, + ); + + await _refreshSlots(); + } catch (_) { + if (!mounted) { + return; + } + + AppToast.show( + context, + message: 'Could not erase firmware slot ${slot.slot}.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } finally { + if (mounted) { + setState(() { + _erasingSlotKey = null; + }); + } + } + } + + /// Asks the user to confirm the destructive slot erase operation. + Future _confirmEraseSlot(FirmwareSlotInfo slot) async { + final result = await showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: const Text('Erase Firmware Slot?'), + content: Text( + 'This erases image ${slot.image}, slot ${slot.slot} from the ' + 'device. Use this only to recover from broken or stuck firmware ' + 'updates.', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(false), + ), + PlatformDialogAction( + cupertino: (_, __) => CupertinoDialogActionData( + isDestructiveAction: true, + ), + child: const Text('Erase'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + + return result ?? false; + } + + /// Returns whether the firmware backend should accept erasing [slot]. + bool _canEraseSlot(FirmwareSlotInfo slot) { + return slot.slot > 0 && !slot.active; + } + + /// Returns the raw mcumgr erase channel for the slot. + int? _eraseChannelFor(FirmwareSlotInfo slot) { + return slot.image == 0 ? null : slot.image; + } + /// Opens the external mcumgr web UI that can help erase image slots. Future _openMcumgrWeb() async { final opened = await launchUrl( @@ -148,7 +244,13 @@ class _FotaSlotsPageState extends State { for (var slotIndex = 0; slotIndex < imageSlots.length; slotIndex++) ...[ - _SlotTile(slot: imageSlots[slotIndex]), + _SlotTile( + slot: imageSlots[slotIndex], + canErase: _canEraseSlot(imageSlots[slotIndex]), + isErasing: _erasingSlotKey == _slotKey(imageSlots[slotIndex]), + isEraseBusy: _erasingSlotKey != null, + onErase: () => _eraseFirmwareSlot(imageSlots[slotIndex]), + ), if (slotIndex < imageSlots.length - 1) const SizedBox(height: SensorPageSpacing.sectionGap), ], @@ -166,7 +268,10 @@ class _FotaSlotsPageState extends State { } } -/// Recovery card that points users to an external slot-erasing tool. +/// Creates a stable UI key for state associated with one firmware slot. +String _slotKey(FirmwareSlotInfo slot) => '${slot.image}:${slot.slot}'; + +/// Recovery card that explains the in-app slot erasing action and fallback. class _SlotRecoveryCard extends StatelessWidget { final Future Function() onOpenTool; @@ -216,12 +321,12 @@ class _SlotRecoveryCard extends StatelessWidget { ), const SizedBox(height: 12), Text( - 'A possible tool for this is mcumgr web. If the device is in a broken update state, erasing the slots can clear the image table and let you start the firmware update flow again.', + 'Use the erase action on an inactive secondary slot above to clear the image table and let you start the firmware update flow again.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), Text( - 'Note: It may be necessary to remove the wearable from the app and settings in order to discover it in the mcumgr web tool.', + 'If in-app erasing fails, mcumgr web can be used as a fallback. It may be necessary to remove the wearable from the app and settings in order to discover it there.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), @@ -396,9 +501,17 @@ class _SlotsErrorCard extends StatelessWidget { /// Visual card for one reported firmware slot. class _SlotTile extends StatelessWidget { final FirmwareSlotInfo slot; + final bool canErase; + final bool isErasing; + final bool isEraseBusy; + final VoidCallback onErase; const _SlotTile({ required this.slot, + required this.canErase, + required this.isErasing, + required this.isEraseBusy, + required this.onErase, }); @override @@ -472,6 +585,13 @@ class _SlotTile extends StatelessWidget { _SlotMetadataRow(label: 'Image', value: '${slot.image}'), const SizedBox(height: 8), _SlotMetadataRow(label: 'Hash', value: _formatHash(slot.hashString)), + const SizedBox(height: 12), + _SlotEraseAction( + canErase: canErase, + isErasing: isErasing, + isEraseBusy: isEraseBusy, + onErase: onErase, + ), ], ), ); @@ -487,6 +607,83 @@ class _SlotTile extends StatelessWidget { } } +/// Destructive action surface for erasing an eligible firmware slot. +class _SlotEraseAction extends StatelessWidget { + final bool canErase; + final bool isErasing; + final bool isEraseBusy; + final VoidCallback onErase; + + const _SlotEraseAction({ + required this.canErase, + required this.isErasing, + required this.isEraseBusy, + required this.onErase, + }); + + @override + Widget build(BuildContext context) { + if (!canErase) { + return _SlotEraseNotice( + icon: Icons.lock_outline_rounded, + text: + 'This slot is protected because it is primary or active.', + ); + } + + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: isEraseBusy ? null : onErase, + icon: isErasing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline_rounded, size: 18), + label: Text(isErasing ? 'Erasing slot' : 'Erase slot'), + ), + ); + } +} + +/// Compact explanation for slots that cannot be erased safely. +class _SlotEraseNotice extends StatelessWidget { + final IconData icon; + final String text; + + const _SlotEraseNotice({ + required this.icon, + required this.text, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } +} + /// Compact label-value row for slot metadata. class _SlotMetadataRow extends StatelessWidget { final String label; diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 39bfb0ef..57be32d1 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import device_info_plus import file_picker import file_selector_macos import flutter_archive +import mcumgr_flutter import open_file_mac import package_info_plus import share_plus @@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) + McumgrFlutterPlugin.register(with: registry.registrar(forPlugin: "McumgrFlutterPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 39926ab2..08d572dc 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -547,11 +555,12 @@ packages: mcumgr_flutter: dependency: "direct main" description: - name: mcumgr_flutter - sha256: fbf2f621dea23dd5dc70494e700c5d4706010841b9e68739ba563cbd88c7e8ba - url: "https://pub.dev" - source: hosted - version: "0.6.1" + path: "." + ref: master + resolved-ref: "7bec874051397e0e0dcbce4a1a660e14be5a3eb3" + url: "https://github.com/DennisMoschina/Flutter-nRF-Connect-Device-Manager.git" + source: git + version: "0.8.1" meta: dependency: transitive description: @@ -595,10 +604,11 @@ packages: open_earable_flutter: dependency: "direct main" description: - name: open_earable_flutter - sha256: b55a2e70ab5ee7ce7d46cebd65f1463a0a83684aa5849e9e3e2526471c0a4b02 - url: "https://pub.dev" - source: hosted + path: "." + ref: "feat/erase-image" + resolved-ref: "7be633468d1730ca58b9df97dec462829fa399f0" + url: "https://github.com/OpenEarable/open_earable_flutter.git" + source: git version: "2.3.6" open_file: dependency: "direct main" @@ -824,14 +834,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" protobuf: dependency: transitive description: name: protobuf - sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "6.0.0" provider: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 510d1c8b..69f702d7 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +35,10 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: ^2.3.5 + open_earable_flutter: + git: + url: https://github.com/OpenEarable/open_earable_flutter.git + ref: feat/erase-image universal_ble: ^0.21.1 flutter_platform_widgets: ^10.0.1 provider: ^6.1.2 @@ -47,7 +50,10 @@ dependencies: flutter_bloc: ^9.1.1 fl_chart: ^1.0.0 file_picker: ^10.3.7 - mcumgr_flutter: ^0.6.1 + mcumgr_flutter: + git: + url: https://github.com/DennisMoschina/Flutter-nRF-Connect-Device-Manager.git + ref: master file_selector: ^1.0.3 path_provider: ^2.1.5 share_plus: ^12.0.1