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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions open_wearable/docs/pages/fota-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
205 changes: 201 additions & 4 deletions open_wearable/lib/widgets/fota/fota_slots_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class _FotaSlotsPageState extends State<FotaSlotsPage> {
Uri.parse('https://boogie.github.io/mcumgr-web/');

late Future<List<FirmwareSlotInfo>> _slotFuture;
String? _erasingSlotKey;

@override
void initState() {
Expand Down Expand Up @@ -60,6 +61,101 @@ class _FotaSlotsPageState extends State<FotaSlotsPage> {
await future;
}

/// Confirms and erases the secondary firmware slot represented by [slot].
Future<void> _eraseFirmwareSlot(FirmwareSlotInfo slot) async {
if (!_canEraseSlot(slot) ||
_erasingSlotKey != null ||
!widget.device.hasCapability<FotaSlotInfoCapability>()) {
return;
}

final confirmed = await _confirmEraseSlot(slot);
if (!confirmed || !mounted) {
return;
}

final slotKey = _slotKey(slot);
setState(() {
_erasingSlotKey = slotKey;
});

try {
final capability =
widget.device.requireCapability<FotaSlotInfoCapability>();
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<bool> _confirmEraseSlot(FirmwareSlotInfo slot) async {
final result = await showPlatformDialog<bool>(
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: <Widget>[
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<void> _openMcumgrWeb() async {
final opened = await launchUrl(
Expand Down Expand Up @@ -148,7 +244,13 @@ class _FotaSlotsPageState extends State<FotaSlotsPage> {
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),
],
Expand All @@ -166,7 +268,10 @@ class _FotaSlotsPageState extends State<FotaSlotsPage> {
}
}

/// 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<void> Function() onOpenTool;

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
],
),
);
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand Down
40 changes: 29 additions & 11 deletions open_wearable/pubspec.lock
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions open_wearable/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading