diff --git a/Apps/MetaWear/MetaWear/Features/Scan/MetaBootDeviceRow.swift b/Apps/MetaWear/MetaWear/Features/Scan/MetaBootDeviceRow.swift new file mode 100644 index 0000000..ee4814d --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/Scan/MetaBootDeviceRow.swift @@ -0,0 +1,43 @@ +import SwiftUI +import MetaWear + +/// Row for a MetaWear board observed advertising in bootloader (MetaBoot) +/// mode. Distinct visual language from `NearbyDeviceRow`: +/// • wrench icon (this is a rescue / recovery flow, not a normal connect); +/// • `Palette.warning` accent to signal the board is in a non-normal state; +/// • no RSSI badge — MetaBoot ads carry the same signal but the value is +/// rarely actionable during the seconds a user spends flashing. +/// +/// Tap opens the firmware-update sheet from `ScanView`. +struct MetaBootDeviceRow: View { + let advertisement: MetaBootAdvertisement + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + Image(systemName: "wrench.and.screwdriver.fill") + .font(.title3) + .foregroundStyle(Palette.warning) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(advertisement.name) + .font(.body.weight(.medium)) + Text(advertisement.identifier.uuidString.prefix(8)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Text("Bootloader") + .font(.caption.weight(.medium)) + .foregroundStyle(Palette.warning) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Palette.warning.opacity(0.15))) + .accessibilityLabel("In bootloader mode") + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} diff --git a/Apps/MetaWear/MetaWear/Features/Scan/MetaBootUpdateView.swift b/Apps/MetaWear/MetaWear/Features/Scan/MetaBootUpdateView.swift new file mode 100644 index 0000000..2d0f7fc --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/Scan/MetaBootUpdateView.swift @@ -0,0 +1,229 @@ +import SwiftUI +import MetaWear +import MetaWearFirmware + +/// Sheet presented from `ScanView` when the user taps a MetaBoot-mode +/// device row. Auto-latest firmware flash only — no file-picker fallback, +/// no version selection. Failure = show the error, offer a retry, and +/// direct the user to reconnect the board. +struct MetaBootUpdateView: View { + + let advertisement: MetaBootAdvertisement + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: MetaBootUpdateViewModel? + + var body: some View { + NavigationStack { + Group { + if let vm = viewModel { + content(vm: vm) + } else { + ProgressView() + } + } + .navigationTitle("Update Firmware") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Close") { dismiss() } + .disabled(viewModel?.isBusy == true) + } + } + } + .task { + if viewModel == nil { + viewModel = MetaBootUpdateViewModel(advertisement: advertisement) + await viewModel?.prepareFlash() + } + } + } + + // MARK: - Body per phase + + @ViewBuilder + private func content(vm: MetaBootUpdateViewModel) -> some View { + switch vm.phase { + case .idle, .loadingDeviceInfo: + loadingBody + case .readyToFlash(let build, let info): + readyBody(vm: vm, build: build, info: info) + case .flashing(let progress): + flashingBody(progress: progress) + case .completed: + completedBody + case .failed(let message): + failedBody(vm: vm, message: message) + } + } + + // MARK: - Phase views + + private var loadingBody: some View { + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + Text("Reading device information…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private func readyBody( + vm: MetaBootUpdateViewModel, + build: MWFirmwareBuild, + info: MetaBootDeviceInfo + ) -> some View { + VStack(alignment: .leading, spacing: 24) { + deviceHeader + VStack(alignment: .leading, spacing: 12) { + Text("Device") + .font(.headline) + infoRow("Hardware", info.hardwareRevision) + infoRow("Model", info.modelNumber) + infoRow("Bootloader", info.bootloaderVersion) + } + VStack(alignment: .leading, spacing: 12) { + Text("Firmware to flash") + .font(.headline) + infoRow("Version", build.firmwareRev) + if let bootloader = build.requiredBootloader, !bootloader.isEmpty { + infoRow("Requires bootloader ≥", bootloader) + } + } + Spacer(minLength: 0) + Button { + Task { await vm.flashLatest() } + } label: { + Label("Flash Latest Firmware", systemImage: "arrow.down.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding() + } + + private func flashingBody(progress: DFUProgress) -> some View { + VStack(spacing: 24) { + deviceHeader + VStack(spacing: 12) { + phaseLabel(for: progress.state) + .font(.headline) + ProgressView( + value: progress.state == .uploading ? progress.percentComplete : 0, + total: 100 + ) + .progressViewStyle(.linear) + if progress.state == .uploading { + HStack { + Text("\(Int(progress.percentComplete))%") + Spacer() + if progress.totalParts > 1 { + Text("Part \(progress.currentPart) of \(progress.totalParts)") + } + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + Text("Do not disconnect the board or close the app.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer(minLength: 0) + } + .padding() + } + + private var completedBody: some View { + VStack(spacing: 20) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(Palette.info) + Text("Firmware Updated") + .font(.title2.weight(.semibold)) + Text("The board is rebooting into application mode. Turn off MetaBoot and reconnect from the scan list.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding() + } + + private func failedBody(vm: MetaBootUpdateViewModel, message: String) -> some View { + VStack(spacing: 20) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(Palette.warning) + Text("Update Failed") + .font(.title3.weight(.semibold)) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + Spacer() + Button { + Task { await vm.prepareFlash() } + } label: { + Label("Try Again", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding() + } + + // MARK: - Bits + + private var deviceHeader: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "wrench.and.screwdriver.fill") + .foregroundStyle(Palette.warning) + Text(advertisement.name) + .font(.title3.weight(.semibold)) + } + Text(advertisement.identifier.uuidString.prefix(8)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + + private func infoRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .font(.body.monospacedDigit()) + } + } + + private func phaseLabel(for state: DFUProgress.State) -> Text { + switch state { + case .fetchingCatalog: return Text("Fetching catalog…") + case .downloadingFirmware: return Text("Downloading firmware…") + case .bootloaderHandoff: return Text("Switching to bootloader…") + case .scanning: return Text("Connecting…") + case .connecting: return Text("Connecting…") + case .starting: return Text("Starting…") + case .validating: return Text("Validating…") + case .uploading: return Text("Uploading firmware…") + case .disconnecting: return Text("Finishing…") + case .completed: return Text("Done") + case .aborted: return Text("Aborted") + } + } +} diff --git a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift index 71e3f2b..6c05c35 100644 --- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift +++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift @@ -12,6 +12,11 @@ struct ScanView: View { let showDetail: () -> Void @State private var viewModel: ScannerViewModel? + /// Which MetaBoot device (if any) the user has tapped, driving the + /// firmware-update sheet. `.sheet(item:)` presents/dismisses as this + /// becomes non-nil / nil. + @State private var metaBootUpdateTarget: MetaBootAdvertisement? + private var pinnedID: UUID? { appStore.rememberedDevices.first?.peripheralUUID } @@ -25,6 +30,79 @@ struct ScanView: View { TimelineView(.periodic(from: .now, by: 1)) { timeline in content(now: timeline.date) } + .navigationTitle(viewModel?.isMetaBootMode == true ? "MetaBoot" : "MetaWear") + // Brand the scan column the way the original app did: a flat, + // full-bleed brand orange (the old connect screen was solid #FE9500 + // with white chrome — no gradient, no motion). Hide the List's + // opaque background so the orange shows through; the rows keep + // their own glass material for contrast. (The RootView-level + // background sits behind the split view, but the sidebar column + // composites its own background above it, so it must be applied + // here too.) + .scrollContentBackground(.hidden) + .background { + BrandScanBackground() + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel?.toggleScan() + } label: { + Label( + viewModel?.isScanning == true ? "Stop Scanning" : "Scan", + systemImage: viewModel?.isScanning == true ? "stop.circle" : "antenna.radiowaves.left.and.right" + ) + } + } + // MetaBoot mode toggle. Sits next to the Scan/Stop button so + // "toggle MetaBoot scanning on/off just like regular scanning" + // maps to the exact same toolbar affordance. Wrench icon is + // consistent with the row treatment (this is a rescue flow), + // and swaps to `checkmark.circle.fill` when active so the + // ON state is legible at a glance. + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel?.toggleMetaBootMode() + } label: { + Label( + viewModel?.isMetaBootMode == true ? "Exit MetaBoot" : "MetaBoot", + systemImage: viewModel?.isMetaBootMode == true + ? "checkmark.circle.fill" + : "wrench.and.screwdriver" + ) + } + .tint(viewModel?.isMetaBootMode == true ? Palette.warning : nil) + } + ToolbarItem(placement: .topBarTrailing) { + // Group logging — log on several boards at once, MetaBase + // style. Badged red while a fleet is recording so the way + // back to Stop & Download stays discoverable. VALUE-based + // push, deliberately: a screen presented via an + // isPresented destination can't resolve value links tapped + // inside it (the path doesn't contain the screen), which + // silently broke "View Saved Sessions" on the group page. + NavigationLink(value: DeviceFeaturePane.groupLogging) { + Label("Group Logging", systemImage: "square.stack.3d.down.right") + } + .tint(hasActiveGroup ? Palette.danger : nil) + } + } + .task { + if viewModel == nil { + viewModel = ScannerViewModel(scanner: appStore.scanner) + } + viewModel?.startScan() + } + .onDisappear { viewModel?.stopScan() } + .sheet(item: $metaBootUpdateTarget, onDismiss: { + // Rebuild the bootloader list from a fresh scan. A successfully + // flashed board has rebooted into application mode, but its + // stale MetaBoot entry would otherwise linger — same UUID, still + // advertising, so the freshness window never culls it. + viewModel?.refreshMetaBootScan() + }) { advertisement in + MetaBootUpdateView(advertisement: advertisement) + } } /// Section header in white — the original app set all its chrome in @@ -35,7 +113,58 @@ struct ScanView: View { .foregroundStyle(.white) } + @ViewBuilder private func content(now: Date) -> some View { + if viewModel?.isMetaBootMode == true { + metaBootContent() + } else { + normalContent(now: now) + } + } + + // MARK: - MetaBoot-mode list + // + // Mode-switch semantics: when the toggle is on, the whole scan list + // becomes the bootloader-mode list. Remembered / Nearby / Demo don't + // appear (they'd be empty and misleading) — leaving only the bootloader + // devices makes it visually obvious the app is in a different mode. + private func metaBootContent() -> some View { + List { + Section(header: brandHeader("Bootloader Mode")) { + Text("Boards currently in Nordic DFU / MetaBoot mode. Tap to flash the latest firmware.") + .font(.footnote) + .foregroundStyle(.secondary) + let devices = viewModel?.metaBootDevices ?? [] + if appStore.scanner.isBluetoothUnavailable { + Label { + Text(bluetoothUnavailableMessage) + .font(.footnote) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .foregroundStyle(Palette.warning) + } + } else if devices.isEmpty { + Text(viewModel?.isScanning == true + ? "Scanning for bootloader-mode boards…" + : "Tap Scan to look for boards in bootloader mode.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(devices) { advertisement in + MetaBootDeviceRow( + advertisement: advertisement, + onTap: { metaBootUpdateTarget = advertisement } + ) + } + } + } + } + } + + // MARK: - Normal list + + private func normalContent(now: Date) -> some View { List { Section(header: brandHeader("Remembered")) { if appStore.rememberedDevices.isEmpty { @@ -150,51 +279,6 @@ struct ScanView: View { } } } - .navigationTitle("MetaWear") - // Brand the scan column the way the original app did: a flat, - // full-bleed brand orange (the old connect screen was solid #FE9500 - // with white chrome — no gradient, no motion). Hide the List's - // opaque background so the orange shows through; the rows keep - // their own glass material for contrast. (The RootView-level - // background sits behind the split view, but the sidebar column - // composites its own background above it, so it must be applied - // here too.) - .scrollContentBackground(.hidden) - .background { - BrandScanBackground() - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - viewModel?.toggleScan() - } label: { - Label( - viewModel?.isScanning == true ? "Stop Scanning" : "Scan", - systemImage: viewModel?.isScanning == true ? "stop.circle" : "antenna.radiowaves.left.and.right" - ) - } - } - ToolbarItem(placement: .topBarTrailing) { - // Group logging — log on several boards at once, MetaBase - // style. Badged red while a fleet is recording so the way - // back to Stop & Download stays discoverable. VALUE-based - // push, deliberately: a screen presented via an - // isPresented destination can't resolve value links tapped - // inside it (the path doesn't contain the screen), which - // silently broke "View Saved Sessions" on the group page. - NavigationLink(value: DeviceFeaturePane.groupLogging) { - Label("Group Logging", systemImage: "square.stack.3d.down.right") - } - .tint(hasActiveGroup ? Palette.danger : nil) - } - } - .task { - if viewModel == nil { - viewModel = ScannerViewModel(scanner: appStore.scanner) - } - viewModel?.startScan() - } - .onDisappear { viewModel?.stopScan() } } /// True while any pending session carries a group tag — a fleet is diff --git a/Apps/MetaWear/MetaWear/ViewModels/MetaBootUpdateViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/MetaBootUpdateViewModel.swift new file mode 100644 index 0000000..770110a --- /dev/null +++ b/Apps/MetaWear/MetaWear/ViewModels/MetaBootUpdateViewModel.swift @@ -0,0 +1,102 @@ +import Foundation +import Observation +import MetaWear +import MetaWearFirmware +import os + +/// Presentation model for the "Flash a MetaBoot-mode device" sheet reached +/// from `ScanView` while the scanner is in MetaBoot mode. +/// +/// Wraps `MetaBootFirmwareUpdater.updateFirmwareToLatest(identifier:)`: +/// reads the board's hardware/model from its MetaBoot Device Information +/// Service, looks up the latest firmware on the MbientLab catalog, and +/// flashes it. The "latest only" scope is deliberate — the app doesn't +/// offer a file picker for MetaBoot rescue flows. +@Observable +@MainActor +final class MetaBootUpdateViewModel { + + /// One coarse UI state for the sheet. Associated values carry exactly + /// what each state needs to render. + enum Phase: Equatable { + /// Just opened; ready to start the flow. + case idle + /// Reading the DIS to identify the board (hardware/model needed to + /// pick the right catalog row). + case loadingDeviceInfo + /// Board identified, we know what firmware we'd flash. Show a + /// confirm-style panel with the version. + case readyToFlash(MWFirmwareBuild, deviceInfo: MetaBootDeviceInfo) + /// Flash in progress; the value is the latest `DFUProgress` event. + case flashing(DFUProgress) + /// Flash finished successfully. Board should reboot into + /// application mode on its own within a few seconds. + case completed + /// Anything failed; the value is a user-facing message. + case failed(String) + } + + let advertisement: MetaBootAdvertisement + + private(set) var phase: Phase = .idle + + /// True while a network / DIS read / flash is in flight. Drives the + /// action button's disabled state. + var isBusy: Bool { + switch phase { + case .loadingDeviceInfo, .flashing: return true + default: return false + } + } + + init(advertisement: MetaBootAdvertisement) { + self.advertisement = advertisement + } + + // MARK: - Actions + + /// Step 1 for the sheet: identify the board and look up the target + /// build. Split from `flashLatest` so a network hiccup during catalog + /// lookup lets the user retry without a fresh CoreBluetooth probe. + /// + /// Any catalog / probe failure lands in `.failed`; no file-picker + /// fallback (per spec — MetaBoot mode in the app is auto-latest only). + func prepareFlash(server: MWFirmwareServer = MWFirmwareServer()) async { + phase = .loadingDeviceInfo + do { + let info = try await MetaBootDeviceInfo.read(identifier: advertisement.identifier) + let build = try await server.latestBuild( + hardwareRev: info.hardwareRevision, + modelNumber: info.modelNumber + ) + phase = .readyToFlash(build, deviceInfo: info) + } catch { + phase = .failed(message(for: error)) + } + } + + /// Step 2: kick off the flash. Streams `DFUProgress` into `phase`; + /// terminal state is either `.completed` or `.failed`. + func flashLatest() async { + var sawCompleted = false + do { + for try await progress in MetaBootFirmwareUpdater.updateFirmwareToLatest( + identifier: advertisement.identifier + ) { + phase = .flashing(progress) + if progress.state == .completed { sawCompleted = true } + } + } catch { + phase = .failed(message(for: error)) + return + } + // Nordic's stream can finish without a `.completed` event if the + // flash aborted cleanly (e.g. user cancelled iteration). Only + // claim success when we actually saw the completion state. + phase = sawCompleted ? .completed : .failed("Flash ended without completing.") + } + + private func message(for error: Error) -> String { + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } +} diff --git a/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift index 41d0402..ac03569 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift @@ -33,6 +33,46 @@ final class ScannerViewModel { scanner.advertisedNames[id] } + // MARK: - MetaBoot mode + // + // The scanner has a single active scan mode: `.metaWear` (default) or + // `.metaBoot`. In MetaBoot mode the discoveredDevices list stops + // updating and `metaBootDevices` populates instead. The mode-switch + // semantics (one or the other, never both) come from the SDK — this + // view model just exposes the toggle to the toolbar. + + /// True when the scanner is surfacing bootloader-mode boards instead of + /// application-mode boards. + var isMetaBootMode: Bool { scanner.scanMode == .metaBoot } + + /// Bootloader-mode boards currently on air, sorted by identifier for a + /// stable UI order. There's no advertised-name cache for MetaBoot to + /// sort against — the local name is almost always the literal string + /// "MetaBoot". + var metaBootDevices: [MetaBootAdvertisement] { + Array(scanner.discoveredMetaBootDevices.values) + .sorted { $0.identifier.uuidString < $1.identifier.uuidString } + } + + /// Flip between application-mode and bootloader-mode scanning. Clears + /// the "other mode"'s discovered-devices list — mode is either/or, so + /// leaving stale entries would confuse the "one mode's devices at a + /// time" invariant. + func toggleMetaBootMode() { + scanner.setScanMode(scanner.scanMode == .metaBoot ? .metaWear : .metaBoot) + } + + /// Wipe the MetaBoot list and let the scan rebuild it from live + /// advertisements. Called when the firmware-update sheet closes: a + /// just-flashed board is back in application mode, but its UUID keeps + /// advertising (as a MetaWear now), so freshness alone would never cull + /// the stale bootloader entry. Boards genuinely still in MetaBoot + /// re-appear within about a second. + func refreshMetaBootScan() { + scanner.clearMetaBootDevices() + scanner.startScan() // no-op if the scan is already running + } + // Connected-state RSSI polling lives in AppStore (`connectedRSSI`): the // connection lifecycle is owned there, and a connected board stops // advertising, so scan-side RSSI has nothing to say about it. diff --git a/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj b/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj index 4b58c24..68f91a4 100644 --- a/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj +++ b/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj @@ -428,7 +428,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 10.1; + MARKETING_VERSION = 10.2; PRODUCT_BUNDLE_IDENTIFIER = com.mbientlab.MetaWearApp; PRODUCT_MODULE_NAME = MetaWearApp; PRODUCT_NAME = MetaWearApp; @@ -465,7 +465,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 10.1; + MARKETING_VERSION = 10.2; PRODUCT_BUNDLE_IDENTIFIER = com.mbientlab.MetaWearApp; PRODUCT_MODULE_NAME = MetaWearApp; PRODUCT_NAME = MetaWearApp; diff --git a/README.md b/README.md index dae1075..1a7b8f8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repository contains both the reusable Swift Package products and the MetaWe | [Architecture](#architecture) | Understanding the scanner/device/protocol/transport layering | | [Supported sensors and modules](#supported-sensors-and-modules) | Finding the Swift type and configuration shape for each MetaWear module | | [Logging](#logging) | On-device flash logging, typed downloads, anonymous logger recovery, and CSV export | +| [Firmware updates](#firmware-updates) | Over-the-air DFU — catalog checks, flashing, bootloader chaining, and MetaBoot recovery | | [Persistence (SwiftData)](#persistence-swiftdata) | Saving downloaded sessions and reconstructing typed samples | | [Testing](#testing) | Running unit tests, hardware integration tests, and the macOS CLI demo | @@ -89,6 +90,7 @@ consumer of the SDK: the public APIs an app needs are exercised here end to end. | **Session history** | Browse saved sessions, re-plot them, and export any session to CSV (Files / AirDrop / email) | | **Controls** | Single-shot reads (temperature, pressure, ambient light), plus LED, haptic, and other module actions | | **Device info & settings** | Battery, signal strength, serial / firmware / model, and per-device settings | +| **Firmware & recovery** | Over-the-air firmware updates from Device Settings, plus a MetaBoot Mode toggle on the scan screen that finds boards stuck in bootloader mode and reflashes them to the latest release | ### Running it @@ -274,11 +276,18 @@ public final class MetaWearScanner { public func startScan() public func stopScan() public func clearAdvertisedName(for uuid: UUID) // force next scan to recapture + + // Scan modes: application-mode boards (default) or bootloader-mode boards + public private(set) var scanMode: ScanMode // .metaWear | .metaBoot + public private(set) var discoveredMetaBootDevices: [UUID: MetaBootAdvertisement] + public func setScanMode(_ mode: ScanMode) } ``` `advertisedNames` is updated on every scan result, **before** the MetaWear-prefix filter, so a device that has been renamed via `MWSettings.SetDeviceName` (and no longer advertises as `"MetaWear…"`) is still observable by UUID. Combined with `clearAdvertisedName(for:)`, this is how the settings integration test verifies that a rename reached the air. +The scanner runs in one of two **modes** at a time. In the default `.metaWear` mode, application-firmware boards populate `discoveredDevices`. In `.metaBoot` mode, boards advertising the Nordic DFU service (a MetaWear stuck in bootloader mode advertises as `"MetaBoot"` and can't be connected normally) populate `discoveredMetaBootDevices` instead — and normal discovery is suspended. Switching modes clears the other mode's list; the underlying CoreBluetooth scan continues uninterrupted, and the ambient caches (names, RSSI, MAC broadcast, last-seen) keep updating in both modes. The MetaBoot list also self-corrects: a board that reboots back into application mode keeps advertising on the same UUID (so freshness culling can never catch it) — its entry is removed the moment an application-mode advertisement is seen, and `clearMetaBootDevices()` wipes the list wholesale for a fresh rebuild (used by the app after a flash completes). See [Firmware updates](#firmware-updates) for the recovery flow this enables. + ### MetaWearDevice `actor` — all state is actor-isolated, thread-safe by default. @@ -1171,6 +1180,82 @@ sources: `MWSensorFusionEulerSignal`, `MWSensorFusionQuaternionSignal`, --- +## Firmware updates + +`import MetaWearFirmware` — a separate SwiftPM product so apps that never +flash firmware don't pull in NordicDFU + ZIPFoundation. Every update entry +point returns `AsyncThrowingStream`: drive a progress bar +from the same iteration that catches failure, and cancel the iteration (or its +Task) to abort the transfer. + +### Check and update a connected board + +```swift +import MetaWearFirmware + +// Is something newer on the MbientLab release catalog? +if let build = try await device.checkForFirmwareUpdate() { + print("Update available: \(build.firmwareRev)") +} + +// Flash the latest release. The stream finishes with no events if the board +// is already current (pass forceReinstall: true to reflash regardless). +for try await progress in device.updateFirmwareToLatest() { + print("\(progress.state) \(Int(progress.percentComplete))%") +} + +// The flash reboots the board and leaves the actor's cached state stale: +try await device.connect() +``` + +`updateFirmware(zipURL:)` flashes an explicit firmware file instead of the +catalog build — `.zip` (Nordic DFU distribution package), or raw `.bin` / +`.hex`. Either path handles the bootloader handoff itself: it sends +`[0xFE, 0x02]`, waits for the board to drop the link as it reboots into +MetaBoot, then runs the Nordic DFU transfer against the same peripheral UUID. + +An outdated bootloader is handled automatically on the catalog path: +`BootloaderInterlock` compares the installed bootloader (read from MetaBoot's +Device Information service) against the build's requirement and chains the +needed bootloader-flavor flash before the application stage. Multi-stage +flashes surface through `DFUProgress.currentPart` / `totalParts`; `.completed` +is emitted only when the final stage finishes. + +### Rescue a board stuck in MetaBoot (bootloader mode) + +A board whose application flash never completed sits in bootloader mode: it +advertises as **"MetaBoot"** with the Nordic DFU service instead of the +MetaWear service, so the normal connect flow can't talk to it. Switch the +scanner into MetaBoot mode to find it, then flash it by UUID — no +`MetaWearDevice` and no handoff involved: + +```swift +scanner.setScanMode(.metaBoot) // suspends normal discovery (either/or) +scanner.startScan() +// … scanner.discoveredMetaBootDevices populates … +guard let target = scanner.discoveredMetaBootDevices.values.first else { return } + +// Optional: identify the board first. MetaBoot's Device Information service +// reports hardware revision, model number, and the bootloader version. +let info = try await MetaBootDeviceInfo.read(identifier: target.identifier) + +// Flash the latest catalog release for that hardware. Reads the DIS itself, +// picks the catalog row, and applies the same bootloader interlock: +for try await progress in MetaBootFirmwareUpdater.updateFirmwareToLatest( + identifier: target.identifier +) { + print("\(progress.state) \(Int(progress.percentComplete))%") +} + +scanner.setScanMode(.metaWear) // back to normal discovery +``` + +`MetaBootFirmwareUpdater.updateFirmware(identifier:zipURL:)` is the +explicit-file variant. In the app, this whole flow is the **MetaBoot Mode** +toggle on the scan screen: flip it on, tap the stranded board, flash latest. + +--- + ## Persistence (SwiftData) The `MetaWearPersistence` library is a separate SwiftPM product that stores downloaded log sessions in SwiftData. It targets iOS 17 / macOS 14 (the same platforms as the core SDK) and ships its own test target (`MetaWearPersistenceTests`). diff --git a/Sources/MetaWear/MetaBootAdvertisement.swift b/Sources/MetaWear/MetaBootAdvertisement.swift new file mode 100644 index 0000000..feffdcd --- /dev/null +++ b/Sources/MetaWear/MetaBootAdvertisement.swift @@ -0,0 +1,44 @@ +// +// MetaBootAdvertisement.swift +// MetaWear +// +// Lightweight descriptor for a MetaWear that has been observed advertising +// in MetaBoot (bootloader) mode. +// +// In MetaBoot mode the board doesn't expose the MetaWear command service, +// so there's no `MetaWearDevice` to hand out — the normal connect/read/ +// write pipeline can't talk to it. What the app *can* do is: +// • identify it (name + UUID + last seen); +// • connect briefly via CoreBluetooth to read the Device Information +// Service (`MetaBootDeviceInfo.read(identifier:)` in the +// `MetaWearFirmware` module); +// • hand the same UUID to the Nordic DFU library to flash firmware. +// +// This descriptor is populated by `MetaWearScanner` while +// `scanMode == .metaBoot`. It carries only what's meaningful without a +// MetaWear command channel — richer facts (bootloader version, hardware +// revision, model number) come from the DIS read in `MetaWearFirmware`. +// + +import Foundation + +public struct MetaBootAdvertisement: Sendable, Equatable, Hashable, Identifiable { + + /// CoreBluetooth peripheral UUID — same identity the board uses in + /// application mode (CoreBluetooth keys peripherals by hardware MAC, + /// not by advertised service set). + public let identifier: UUID + + /// Advertised local name. Almost always the literal string + /// `"MetaBoot"`; kept as a stored property so the caller UI has a + /// human-readable label even for rare boards that customise the + /// bootloader name. + public let name: String + + public var id: UUID { identifier } + + public init(identifier: UUID, name: String = "MetaBoot") { + self.identifier = identifier + self.name = name + } +} diff --git a/Sources/MetaWear/MetaWearScanner.swift b/Sources/MetaWear/MetaWearScanner.swift index 2b8ebf4..46947c1 100644 --- a/Sources/MetaWear/MetaWearScanner.swift +++ b/Sources/MetaWear/MetaWearScanner.swift @@ -6,13 +6,47 @@ import Foundation /// `@MainActor` isolation ensures all mutations to `discoveredDevices` and `isScanning` /// happen on the main thread — safe to bind directly to SwiftUI views via `@Observable`. /// One scanner per app; each discovered peripheral gets its own isolated transport. +/// +/// The scanner runs in one of two modes at a time (`scanMode`): +/// • `.metaWear` — the default. Application-mode boards populate +/// `discoveredDevices`; MetaBoot advertisements are ignored. +/// • `.metaBoot` — bootloader-mode boards populate +/// `discoveredMetaBootDevices`; application-mode ads are ignored. +/// +/// Only one mode's devices are ever visible at once. The underlying +/// CoreBluetooth scan is a single session shared across modes — switching +/// mode is just re-gating which advertisements produce vended entries. +/// Ambient side-effect caches (`advertisedNames`, `advertisementRSSI`, +/// `advertisedMACs`, `advertisementLastSeen`, `advertisementManufacturerData`) +/// keep updating from EVERY observed advertisement regardless of mode so +/// remembered-device / rename / MAC-broadcast workflows work uninterrupted. @Observable @MainActor public final class MetaWearScanner { + // MARK: - Scan mode + + /// Which class of MetaWear advertisement the scanner surfaces into its + /// vended device dictionaries. See the class documentation for details. + public enum ScanMode: Sendable, Equatable { + /// Application-mode boards (default). Populates `discoveredDevices`. + case metaWear + /// Bootloader-mode boards. Populates `discoveredMetaBootDevices`. + case metaBoot + } + + /// Current scan mode. Change via `setScanMode(_:)`. + public private(set) var scanMode: ScanMode = .metaWear + // MARK: - Public state public private(set) var discoveredDevices: [UUID: MetaWearDevice] = [:] + + /// MetaBoot-mode boards discovered while `scanMode == .metaBoot`. + /// Only populated in that mode — application-mode ads never appear + /// here, and this dictionary is cleared when leaving MetaBoot mode. + public private(set) var discoveredMetaBootDevices: [UUID: MetaBootAdvertisement] = [:] + public private(set) var isScanning = false /// Most-recently-seen advertised local name for each peripheral UUID the @@ -157,33 +191,116 @@ public final class MetaWearScanner { } } mwLogVerbose("[Scanner] discovered: \(id) name='\(name)'") - guard Self.isMetaWearAdvertisement( - name: name, - serviceUUIDs: result.serviceUUIDs, - manufacturerData: result.manufacturerData - ) else { continue } - guard self.discoveredDevices[id] == nil else { continue } - mwLog("[Scanner] new MetaWear device: \(id)") - if let cached = self.knownDevices.removeValue(forKey: id) { - // Promote the known-peripheral instance instead of minting - // a twin. Two MetaWearDevice instances for one UUID means - // two transports fighting over MWCentralManager's per-UUID - // callback routing — the loser's connection goes dark (its - // didDisconnect is never delivered). With several - // remembered boards reconnecting while a scan runs, that - // race would be routine. - self.discoveredDevices[id] = cached - } else { - let transport = CoreBluetoothPeripheralTransport( + // Route the advertisement to the mode-specific vended + // dictionary. Ambient caches above kept updating regardless + // — this gate only decides whether the ad becomes a + // vended device entry. Ads for the other mode are dropped + // silently. + switch self.scanMode { + case .metaWear: + guard Self.isMetaWearAdvertisement( + name: name, + serviceUUIDs: result.serviceUUIDs, + manufacturerData: result.manufacturerData + ) else { continue } + guard self.discoveredDevices[id] == nil else { continue } + mwLog("[Scanner] new MetaWear device: \(id)") + if let cached = self.knownDevices.removeValue(forKey: id) { + // Promote the known-peripheral instance instead of minting + // a twin. Two MetaWearDevice instances for one UUID means + // two transports fighting over MWCentralManager's per-UUID + // callback routing — the loser's connection goes dark (its + // didDisconnect is never delivered). With several + // remembered boards reconnecting while a scan runs, that + // race would be routine. + self.discoveredDevices[id] = cached + } else { + let transport = CoreBluetoothPeripheralTransport( + identifier: id, + centralManager: self.centralManager + ) + self.discoveredDevices[id] = MetaWearDevice(identifier: id, transport: transport) + } + case .metaBoot: + guard Self.isMetaBootAdvertisement( + name: name, + serviceUUIDs: result.serviceUUIDs + ) else { + // Self-correct the list: a board that was flashed (or + // power-cycled) back into application mode keeps + // advertising on the SAME UUID — now as a MetaWear, + // not a MetaBoot. Freshness culling can't catch that + // staleness (the UUID's last-seen stays fresh via the + // app-mode ads), so remove the entry the moment an + // app-mode advertisement proves the board left the + // bootloader. Guarded on membership — unrelated + // non-MetaBoot ads cause no observable churn. + if self.discoveredMetaBootDevices[id] != nil, + Self.isMetaWearAdvertisement( + name: name, + serviceUUIDs: result.serviceUUIDs, + manufacturerData: result.manufacturerData + ) { + self.discoveredMetaBootDevices.removeValue(forKey: id) + mwLog("[Scanner] MetaBoot device left bootloader mode: \(id)") + } + continue + } + let advertised = MetaBootAdvertisement( identifier: id, - centralManager: self.centralManager + name: name.isEmpty ? "MetaBoot" : name ) - self.discoveredDevices[id] = MetaWearDevice(identifier: id, transport: transport) + // Guarded on actual change (same rule as ambient caches + // above) — MetaBoot devices re-advertise several times + // per second and unguarded writes would invalidate + // observers at advertising rate. + if self.discoveredMetaBootDevices[id] != advertised { + let isNew = self.discoveredMetaBootDevices[id] == nil + self.discoveredMetaBootDevices[id] = advertised + if isNew { + mwLog("[Scanner] new MetaBoot device: \(id) name='\(advertised.name)'") + } + } } } } } + /// Forget every discovered MetaBoot-mode board so the running scan + /// rebuilds the list from scratch. Boards still in bootloader mode + /// re-appear within about a second (MetaBoot advertises several times + /// per second); boards that have rebooted into application mode do not. + /// The app calls this when the firmware-update sheet closes, so a + /// just-flashed board doesn't linger in the bootloader list. + public func clearMetaBootDevices() { + discoveredMetaBootDevices.removeAll() + } + + /// Switch which class of advertisement the scanner surfaces. + /// + /// Clears the OTHER mode's discovered-devices dictionary so tapping + /// through modes doesn't leave stale entries behind. A running scan + /// continues uninterrupted — only the routing gate changes; ambient + /// caches (names, RSSI, MAC broadcast, last-seen) keep updating. + public func setScanMode(_ mode: ScanMode) { + guard mode != scanMode else { return } + mwLog("[Scanner] setScanMode: \(scanMode) → \(mode)") + scanMode = mode + switch mode { + case .metaWear: + // Leaving MetaBoot: forget the MetaBoot list. The DFU workflow + // is a one-shot from the UI; keeping a stale list around after + // the toggle flips would confuse the "one mode's devices at a + // time" invariant that made mode-switch the chosen model. + discoveredMetaBootDevices.removeAll() + case .metaBoot: + // Leaving MetaWear: forget the application-mode list. Same + // rationale in reverse. Rememebered-device lookups still work + // — they use `advertisementLastSeen` etc. which keep updating. + discoveredDevices.removeAll() + } + } + /// Whether an advertisement belongs to a MetaWear board running /// application firmware, by any of three signals: /// 1. The default local-name prefix ("MetaWear"). @@ -195,7 +312,7 @@ public final class MetaWearScanner { /// /// MetaBoot-mode boards match none of these (name "MetaBoot", Nordic /// DFU service) and stay excluded on purpose — the normal connect flow - /// can't talk to a bootloader. + /// can't talk to a bootloader. Use `isMetaBootAdvertisement` for those. nonisolated static func isMetaWearAdvertisement( name: String, serviceUUIDs: [String], @@ -211,6 +328,30 @@ public final class MetaWearScanner { return false } + /// Whether an advertisement belongs to a MetaWear board running in + /// **MetaBoot** (bootloader) mode. Two signals, either sufficient: + /// 1. Local name exactly `"MetaBoot"` — the Nordic bootloader's + /// default. + /// 2. Nordic DFU service UUID (`00001530-1212-EFDE-1523-785FEABCD123`) + /// in the advertised services — present in every MetaBoot ad + /// regardless of name. + /// + /// Boards in MetaBoot mode DO NOT expose the MetaWear command service, + /// so `MetaWearDevice.connect()` can't talk to them — they surface into + /// the scanner's `discoveredMetaBootDevices` bucket only while + /// `scanMode == .metaBoot`, and are handled by the firmware-update flow + /// via `MetaBootDeviceInfo.read(identifier:)` and the DFU library. + nonisolated static func isMetaBootAdvertisement( + name: String, + serviceUUIDs: [String] + ) -> Bool { + if name == "MetaBoot" { return true } + let dfuService = MWUUIDs.nordicDFUService.uuidString + return serviceUUIDs.contains { + $0.caseInsensitiveCompare(dfuService) == .orderedSame + } + } + /// Override the cached advertised name for `uuid`. /// /// For rename flows: after `MWSettings.SetDeviceName` the cache is diff --git a/Sources/MetaWear/Protocol/MWUUIDs.swift b/Sources/MetaWear/Protocol/MWUUIDs.swift index edfc40c..2f4746f 100644 --- a/Sources/MetaWear/Protocol/MWUUIDs.swift +++ b/Sources/MetaWear/Protocol/MWUUIDs.swift @@ -18,6 +18,16 @@ public enum MWUUIDs { public static let batteryService = CBUUID(string: "180F") public static let batteryLevel = CBUUID(string: "2A19") + // MARK: - Nordic Semiconductor DFU service + // + // Advertised by a MetaWear that has rebooted into MetaBoot (bootloader) + // mode. Nordic's iOS-DFU-Library uses this UUID internally to locate + // its target during a flash; the SDK's scanner uses it to distinguish + // MetaBoot-mode advertisements from application-mode ones. Same UUID + // whether the device is running SDK 12+ Secure DFU or legacy DFU — + // Nordic kept the service UUID stable across bootloader generations. + public static let nordicDFUService = CBUUID(string: "00001530-1212-EFDE-1523-785FEABCD123") + // Note: the Generic Access Service (0x1800) and its Device Name // characteristic (0x2A00) are intentionally omitted. Apple's CoreBluetooth // filters both 0x1800 and 0x1801 from service discovery on iOS/macOS, so diff --git a/Sources/MetaWearFirmware/DFUFlasher.swift b/Sources/MetaWearFirmware/DFUFlasher.swift new file mode 100644 index 0000000..3318821 --- /dev/null +++ b/Sources/MetaWearFirmware/DFUFlasher.swift @@ -0,0 +1,240 @@ +// +// DFUFlasher.swift +// MetaWearFirmware +// +// Extracted low-level DFU orchestration — the parts of the flash pipeline +// that don't need a `MetaWearDevice` actor. Everything here works from +// values (target UUID, firmware file URL, catalog build metadata). +// +// Two call sites: +// • `MetaWearDevice+DFU` — app-mode entry points, which do their own +// handoff via `sendExpectingDisconnect(MWDebug.JumpToBootloader())` +// then delegate here for the actual flashing. +// • `MetaBootFirmwareUpdater` — public entry points for devices ALREADY +// in bootloader mode; skip the handoff and go straight to flashing. +// +// All members are `internal`; the module-level public API is exposed via +// the two callers above. +// + +import Foundation +@preconcurrency import NordicDFU + +enum DFUFlasher { + + // MARK: - Flash stages + + /// Flash one or more firmware images in sequence — bootloader first when + /// the interlock demands it, then the application. Each stage is one + /// Nordic DFU run; after a bootloader stage the board resets back into + /// MetaBoot (there's no valid application to boot into yet). + /// + /// Progress from every stage is renumbered so observers see stage-level + /// `currentPart`/`totalParts`, and `.completed` is suppressed for all but + /// the final stage — only the whole sequence finishing means "done". + static func flashStages( + _ stages: [DFUFirmware], + targetIdentifier: UUID, + continuation: AsyncThrowingStream.Continuation + ) async throws { + let total = stages.count + for (index, firmware) in stages.enumerated() { + // A cancelled task must never start (or continue past) a Nordic + // DFU pass — DFUSession.run fires initiator.start synchronously + // before iteration can observe the cancellation. + try Task.checkCancellation() + let stage = index + 1 + let isLast = stage == total + continuation.yield(DFUProgress( + state: .scanning, currentPart: stage, totalParts: total + )) + do { + try await runDFUPass( + firmware: firmware, targetIdentifier: targetIdentifier, + stage: stage, of: total, isLast: isLast, + continuation: continuation + ) + } catch MWFirmwareError.dfuFailed(let message) + where message.contains("DFU Service not found") { + // Nordic lost the reconnect race (pre-reboot window / stale + // GATT). MetaBoot sits waiting after boot, so one delayed + // retry per stage is safe. + mwFirmwareLog("[DFU] service not found — retrying once after settle delay") + try? await Task.sleep(for: .seconds(2)) + continuation.yield(DFUProgress( + state: .scanning, currentPart: stage, totalParts: total + )) + try await runDFUPass( + firmware: firmware, targetIdentifier: targetIdentifier, + stage: stage, of: total, isLast: isLast, + continuation: continuation + ) + } + if !isLast { + mwFirmwareLog("[DFU] stage \(stage)/\(total) flashed — waiting for reboot into MetaBoot") + // Throwing sleep on purpose: cancellation between stages must + // abort the sequence, not fall through into the next flash. + try await Task.sleep(for: .milliseconds(2500)) + } + } + } + + /// One Nordic DFU attempt. `DFUSession` is single-use, so each pass gets + /// a fresh session and queue. + static func runDFUPass( + firmware: DFUFirmware, + targetIdentifier: UUID, + stage: Int, + of total: Int, + isLast: Bool, + continuation: AsyncThrowingStream.Continuation + ) async throws { + let session = DFUSession() + let queue = DispatchQueue( + label: "com.metawear.firmware.dfu.\(targetIdentifier.uuidString)", + qos: .userInitiated + ) + let dfuStream = session.run( + firmware: firmware, + targetIdentifier: targetIdentifier, + queue: queue + ) + for try await progress in dfuStream { + // A non-final stage finishing is progress, not completion. + if progress.state == .completed && !isLast { continue } + guard total > 1 else { + continuation.yield(progress) + continue + } + continuation.yield(DFUProgress( + state: progress.state, + percentComplete: progress.percentComplete, + currentPart: stage, + totalParts: total, + bytesPerSecond: progress.bytesPerSecond + )) + } + // AsyncThrowingStream's next() returns nil on task cancellation + // instead of throwing — a cancelled iteration must not read as + // stage success (the next stage would flash a half-written board). + if Task.isCancelled { throw MWFirmwareError.aborted } + } + + // MARK: - Firmware parsing / download + + static func makeDFUFirmware(from url: URL) throws -> DFUFirmware { + let ext = url.pathExtension.lowercased() + do { + switch ext { + case "zip": + return try DFUFirmware(urlToZipFile: url) + case "bin", "hex": + return try DFUFirmware( + urlToBinOrHexFile: url, + urlToDatFile: nil, + type: .application + ) + default: + throw MWFirmwareError.invalidFirmwareFile(url) + } + } catch let mwError as MWFirmwareError { + throw mwError + } catch { + // Nordic's parser throws its own errors (zip extraction failure, + // missing manifest, etc.). Wrap them so callers see one error + // taxonomy. + throw MWFirmwareError.invalidFirmwareFile(url) + } + } + + static func downloadToLocal( + _ url: URL, + fetcher: MWFirmwareFetcher + ) async throws -> URL { + let (tempURL, response) = try await fetcher.download(from: url) + guard (200..<300).contains(response.statusCode) else { + try? FileManager.default.removeItem(at: tempURL) + throw MWFirmwareError.badServerResponse(status: response.statusCode) + } + // Re-stage under the source's filename: `makeDFUFirmware` dispatches + // on the extension, and the session's temp file ends in ".tmp", which + // would be rejected as an invalid firmware container. + return try MWFirmwareServer.stageDownload( + tempURL: tempURL, + filename: url.lastPathComponent + ) + } + + // MARK: - Bootloader interlock + + /// Read the installed bootloader from MetaBoot and, when it's older than + /// the target build's requirement, download the chain of catalog + /// bootloaders that fixes it (bootloader builds declare requirements of + /// their own, so one upgrade may need stepping stones). + /// + /// A failed PROBE degrades to a single-stage flash (pre-interlock + /// behaviour) rather than blocking the update: near-all boards in the + /// field already run an adequate bootloader, and a flaky characteristic + /// read shouldn't strand them. Cancellation is NOT a probe failure and + /// is rethrown — a cancelled update must never proceed to flash. A + /// confirmed-outdated bootloader with no catalog remedy throws + /// `bootloaderUpgradeUnavailable`; catalog fetch errors propagate as + /// themselves so a transient network blip doesn't masquerade as that + /// terminal verdict. + static func bootloaderStagesIfNeeded( + for build: MWFirmwareBuild, + hardwareRev: String, + modelNumber: String, + server: MWFirmwareServer, + targetIdentifier: UUID, + installedBootloader: String? = nil, + continuation: AsyncThrowingStream.Continuation + ) async throws -> [DFUFirmware] { + let installed: String + if let installedBootloader { + installed = installedBootloader + mwFirmwareLog("[DFU] MetaBoot bootloader supplied: \(installed)") + } else { + do { + installed = try await MetaBootProbe.readBootloaderVersion( + identifier: targetIdentifier + ) + mwFirmwareLog("[DFU] MetaBoot reports bootloader \(installed)") + } catch is CancellationError { + throw CancellationError() + } catch MWFirmwareError.aborted { + throw MWFirmwareError.aborted + } catch { + mwFirmwareLog("[DFU] ⚠️ bootloader probe failed (\(error.localizedDescription)) — flashing application only") + return [] + } + } + let bootloaders = try await server.availableBuilds( + hardwareRev: hardwareRev, + modelNumber: modelNumber, + buildFlavor: "bootloader" + ) + let plan = try BootloaderInterlock.plan( + installedBootloader: installed, + requiredBootloader: build.requiredBootloader, + availableBootloaders: bootloaders, + hardwareRev: hardwareRev + ) + guard case .flashBootloadersFirst(let chain) = plan else { + return [] + } + mwFirmwareLog("[DFU] bootloader \(installed) < required \(build.requiredBootloader ?? "?") — staging \(chain.map(\.firmwareRev).joined(separator: " → "))") + let total = chain.count + 1 + var firmwares: [DFUFirmware] = [] + for (index, bootloaderBuild) in chain.enumerated() { + continuation.yield(DFUProgress( + state: .downloadingFirmware, + currentPart: index + 1, + totalParts: total + )) + let url = try await server.downloadFirmware(bootloaderBuild) + firmwares.append(try makeDFUFirmware(from: url)) + } + return firmwares + } +} diff --git a/Sources/MetaWearFirmware/MetaBootFirmwareUpdater.swift b/Sources/MetaWearFirmware/MetaBootFirmwareUpdater.swift new file mode 100644 index 0000000..8eff404 --- /dev/null +++ b/Sources/MetaWearFirmware/MetaBootFirmwareUpdater.swift @@ -0,0 +1,175 @@ +// +// MetaBootFirmwareUpdater.swift +// MetaWearFirmware +// +// Public DFU entry points for a MetaWear device that is ALREADY in MetaBoot +// (bootloader) mode. Unlike `MetaWearDevice.updateFirmware(...)`, these +// don't send the jump-to-bootloader command — the caller has discovered the +// device via `MetaWearScanner.discoveredMetaBootDevices` (scanner in +// `.metaBoot` mode) and holds only its `UUID`, not a `MetaWearDevice`. +// +// Two entry points: +// • `updateFirmware(identifier:zipURL:)` — explicit zip / bin / hex. +// • `updateFirmwareToLatest(identifier:)` — read hw + model from +// MetaBoot DIS, look up +// latest on the catalog, +// run the interlock, flash. +// +// Both return an `AsyncThrowingStream`. Because the +// device is already in MetaBoot mode, the first yielded state is +// `.scanning` or `.fetchingCatalog` (never `.bootloaderHandoff`). Common +// use case: recovering a board that has been stuck in MetaBoot because a +// previous flash didn't complete, or a user-triggered rescue flash from +// the app's connection screen with MetaBoot mode enabled. +// + +import Foundation + +public enum MetaBootFirmwareUpdater { + + // MARK: - Update from explicit zip URL + + /// Flash the firmware at `zipURL` onto a device already in MetaBoot + /// mode. `identifier` is the CoreBluetooth peripheral UUID observed + /// while the scanner was in `.metaBoot` mode (same UUID the board uses + /// in application mode — CoreBluetooth keys peripherals by hardware + /// MAC, not by advertised service set). + /// + /// Accepted file extensions: `.zip` (Nordic DFU distribution package), + /// `.bin` / `.hex` (raw application image). + public static func updateFirmware( + identifier: UUID, + zipURL: URL, + fetcher: MWFirmwareFetcher = URLSessionFetcher() + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + try await _runExplicitURLFlash( + identifier: identifier, + zipURL: zipURL, + fetcher: fetcher, + continuation: continuation + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Update to catalog-latest + + /// Read the device's hardware revision and model number from its + /// MetaBoot Device Information Service, look up the latest matching + /// firmware on the MbientLab catalog, and flash it. Applies the same + /// bootloader interlock the app-mode `updateFirmwareToLatest` uses — + /// so an outdated bootloader auto-chains a bootloader-flavor stage + /// before the application stage. + /// + /// Fails with `MWFirmwareError.noAvailableFirmware` if the board's + /// (hardware, model) isn't recognised on the catalog. The MetaBoot + /// probe timeout applies to the initial DIS read only (default 10 s). + public static func updateFirmwareToLatest( + identifier: UUID, + server: MWFirmwareServer = MWFirmwareServer() + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + try await _runUpdateToLatest( + identifier: identifier, + server: server, + continuation: continuation + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Drivers + + private static func _runExplicitURLFlash( + identifier: UUID, + zipURL: URL, + fetcher: MWFirmwareFetcher, + continuation: AsyncThrowingStream.Continuation + ) async throws { + // Resolve the firmware URL to a local file. + let localURL: URL + if zipURL.isFileURL { + localURL = zipURL + } else { + continuation.yield(DFUProgress(state: .downloadingFirmware)) + localURL = try await DFUFlasher.downloadToLocal(zipURL, fetcher: fetcher) + } + let firmware = try DFUFlasher.makeDFUFirmware(from: localURL) + + // Device is already in MetaBoot — go straight to the flash. + // No interlock: an explicit-URL flash has no catalog metadata that + // could describe a required bootloader. + try await DFUFlasher.flashStages( + [firmware], + targetIdentifier: identifier, + continuation: continuation + ) + } + + private static func _runUpdateToLatest( + identifier: UUID, + server: MWFirmwareServer, + continuation: AsyncThrowingStream.Continuation + ) async throws { + // Step 1: read the DIS from MetaBoot. Hardware rev + model number + // pick the catalog row; bootloader version feeds the interlock + // without needing a second CB session later. + continuation.yield(DFUProgress(state: .fetchingCatalog)) + let deviceInfo = try await MetaBootDeviceInfo.read(identifier: identifier) + mwFirmwareLog( + "[DFU] MetaBoot DIS: hw=\(deviceInfo.hardwareRevision) model=\(deviceInfo.modelNumber) bootloader=\(deviceInfo.bootloaderVersion)" + ) + + // Step 2: pick the latest firmware for this board on the catalog. + // A MetaBoot device has no application firmware version to compare + // against — always fetch the latest. + let build = try await server.latestBuild( + hardwareRev: deviceInfo.hardwareRevision, + modelNumber: deviceInfo.modelNumber + ) + mwFirmwareLog("[DFU] latest catalog build for hw=\(deviceInfo.hardwareRevision) model=\(deviceInfo.modelNumber): \(build.firmwareRev)") + + // Step 3: download the application image before doing anything + // else, so a network failure bails out cleanly. + continuation.yield(DFUProgress(state: .downloadingFirmware)) + let applicationURL = try await server.downloadFirmware(build) + let application = try DFUFlasher.makeDFUFirmware(from: applicationURL) + + // Step 4: bootloader interlock. We already read the bootloader + // version in step 1 — pass it in so the flasher doesn't spin up a + // second CB probe session. + var flashStages = try await DFUFlasher.bootloaderStagesIfNeeded( + for: build, + hardwareRev: deviceInfo.hardwareRevision, + modelNumber: deviceInfo.modelNumber, + server: server, + targetIdentifier: identifier, + installedBootloader: deviceInfo.bootloaderVersion, + continuation: continuation + ) + flashStages.append(application) + + // Step 5: flash. Device is already in MetaBoot mode, so we go + // straight to Nordic without any handoff. + try await DFUFlasher.flashStages( + flashStages, + targetIdentifier: identifier, + continuation: continuation + ) + } +} diff --git a/Sources/MetaWearFirmware/MetaBootProbe.swift b/Sources/MetaWearFirmware/MetaBootProbe.swift index 93f39dc..e535211 100644 --- a/Sources/MetaWearFirmware/MetaBootProbe.swift +++ b/Sources/MetaWearFirmware/MetaBootProbe.swift @@ -2,15 +2,17 @@ // MetaBootProbe.swift // MetaWearFirmware // -// Reads the bootloader version from a MetaWear that has already rebooted -// into MetaBoot (bootloader) mode. +// Reads Device Information Service characteristics from a MetaWear that has +// already rebooted into MetaBoot (bootloader) mode. // // In MetaBoot mode the standard Device Information Service's Firmware // Revision characteristic (0x2A26) reports the BOOTLOADER version rather // than the application firmware version — and MetaBoot is the only place // the bootloader version is readable at all; application-mode firmware -// doesn't expose it. The bootloader interlock uses this to decide whether -// a bootloader-flavor flash must precede the application flash. +// doesn't expose it. Hardware Revision (0x2A27) and Model Number (0x2A24) +// are readable in both modes and identify the physical board, which the +// app-side "connect to a MetaBoot device" flow needs to look up firmware +// from the MbientLab catalog without having the MetaWear service. // // This is a deliberately tiny, single-shot CoreBluetooth client: the core // SDK's transport can't be reused because its connect sequence requires the @@ -22,13 +24,97 @@ import Foundation @preconcurrency import CoreBluetooth +// MARK: - Public: MetaBootDeviceInfo + +/// A snapshot of the three Device Information Service strings a MetaWear +/// exposes while running in MetaBoot mode. +/// +/// - Important: `bootloaderVersion` is the string reported by the Firmware +/// Revision characteristic (0x2A26). While the board is in MetaBoot mode +/// this describes the *bootloader*, not the application firmware — the +/// application partition may be present, missing, or a different version. +public struct MetaBootDeviceInfo: Sendable, Equatable { + + /// From `2A27` — same physical hardware descriptor exposed in + /// application mode (e.g. `"0.4"`). + public let hardwareRevision: String + + /// From `2A24` — same model number exposed in application mode + /// (e.g. `"5"` for MetaMotion R, `"8"` for MetaMotion S). + public let modelNumber: String + + /// From `2A26` — reports the BOOTLOADER version while the board is in + /// MetaBoot mode, not the application firmware version. + public let bootloaderVersion: String + + public init( + hardwareRevision: String, + modelNumber: String, + bootloaderVersion: String + ) { + self.hardwareRevision = hardwareRevision + self.modelNumber = modelNumber + self.bootloaderVersion = bootloaderVersion + } + + /// Connect to the MetaBoot-mode peripheral with `identifier`, read the + /// three Device Information Service strings, and disconnect. + /// + /// - Note: The board must already be in MetaBoot mode and advertising + /// (e.g. after a jump-to-bootloader handoff, or a board that has been + /// sitting in MetaBoot because the last application flash didn't take). + public static func read( + identifier: UUID, + timeout: TimeInterval = 10 + ) async throws -> MetaBootDeviceInfo { + let results = try await MetaBootProbe.readCharacteristics( + identifier: identifier, + characteristics: [ + MetaBootProbe.hardwareRevision, + MetaBootProbe.modelNumber, + MetaBootProbe.firmwareRevision + ], + timeout: timeout + ) + guard let hardware = results[MetaBootProbe.hardwareRevision.uuidString], + let model = results[MetaBootProbe.modelNumber.uuidString], + let firmware = results[MetaBootProbe.firmwareRevision.uuidString] else { + // Delegates read every requested characteristic before finishing; + // a missing entry means the probe reported success on a partial + // read, which shouldn't happen. Surface it as a clear error so + // a future regression is loud. + throw MWFirmwareError.operationFailed( + "MetaBoot device info read returned an incomplete result." + ) + } + return MetaBootDeviceInfo( + hardwareRevision: hardware, + modelNumber: model, + bootloaderVersion: firmware + ) + } +} + +// MARK: - Internal probe + final class MetaBootProbe: NSObject, @unchecked Sendable { - private static let deviceInformationService = CBUUID(string: "180A") - private static let firmwareRevision = CBUUID(string: "2A26") + // MARK: - Characteristic UUIDs (module-internal so `MetaBootDeviceInfo` can reference them) + + static let deviceInformationService = CBUUID(string: "180A") + static let firmwareRevision = CBUUID(string: "2A26") + static let hardwareRevision = CBUUID(string: "2A27") + static let modelNumber = CBUUID(string: "2A24") + + // MARK: - State private let lock = NSLock() - private var continuation: CheckedContinuation? + // Result dict is keyed by `CBUUID.uuidString` (not the CBUUID itself) + // because CBUUID is not `Sendable` in the CoreBluetooth headers; a + // `[CBUUID: String]` result crossing the continuation boundary trips + // Swift 6's SendingRisksDataRace check. Callers translate back with + // `CBUUID.uuidString` at both ends of the API. + private var continuation: CheckedContinuation<[String: String], Error>? /// Sticky terminal flag, distinct from `continuation == nil`: it lets a /// cancellation that lands BEFORE the continuation is registered still /// take effect (the registration path checks it), and lets delegate @@ -38,22 +124,52 @@ final class MetaBootProbe: NSObject, @unchecked Sendable { private var peripheral: CBPeripheral? private let queue = DispatchQueue(label: "com.metawear.firmware.metabootprobe", qos: .userInitiated) private let targetIdentifier: UUID + private let characteristics: [CBUUID] + private var characteristicsToRead: Set + private var results: [String: String] = [:] - private init(targetIdentifier: UUID) { + private init(targetIdentifier: UUID, characteristics: [CBUUID]) { self.targetIdentifier = targetIdentifier + self.characteristics = characteristics + self.characteristicsToRead = Set(characteristics) } - /// Connect to the MetaBoot-mode peripheral with `identifier`, read the - /// Firmware Revision string (= bootloader version), and disconnect. - /// - /// - Note: The board must already be in MetaBoot mode and advertising — - /// call only after the jump-to-bootloader handoff has completed. + // MARK: - Public entry points + + /// Convenience: read only the bootloader version (Firmware Revision + /// characteristic). Preserves the historic single-value shape that the + /// `BootloaderInterlock` uses. static func readBootloaderVersion( identifier: UUID, timeout: TimeInterval = 10 ) async throws -> String { + let results = try await readCharacteristics( + identifier: identifier, + characteristics: [firmwareRevision], + timeout: timeout + ) + guard let value = results[firmwareRevision.uuidString] else { + throw MWFirmwareError.operationFailed( + "MetaBoot bootloader version read returned no value." + ) + } + return value + } + + /// Read the given DIS characteristics from a MetaBoot-mode peripheral in + /// one connection. Reads are issued sequentially in the order supplied + /// so a single characteristic failure (missing / unreadable) fails the + /// whole probe cleanly. + static func readCharacteristics( + identifier: UUID, + characteristics: [CBUUID], + timeout: TimeInterval = 10 + ) async throws -> [String: String] { try Task.checkCancellation() - let probe = MetaBootProbe(targetIdentifier: identifier) + let probe = MetaBootProbe( + targetIdentifier: identifier, + characteristics: characteristics + ) return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in probe.start(continuation: continuation, timeout: timeout) @@ -63,8 +179,10 @@ final class MetaBootProbe: NSObject, @unchecked Sendable { } } + // MARK: - Lifecycle + private func start( - continuation: CheckedContinuation, + continuation: CheckedContinuation<[String: String], Error>, timeout: TimeInterval ) { lock.lock() @@ -94,7 +212,7 @@ final class MetaBootProbe: NSObject, @unchecked Sendable { } queue.asyncAfter(deadline: .now() + timeout) { [weak self] in self?.finish(throwing: MWFirmwareError.operationFailed( - "Timed out reading the bootloader version from the device." + "Timed out reading Device Information from MetaBoot." )) } } @@ -110,7 +228,10 @@ final class MetaBootProbe: NSObject, @unchecked Sendable { /// One-shot completion: resume the continuation exactly once and tear /// down all CoreBluetooth state, whichever path (value, error, timeout, /// cancellation) gets here first. - private func finish(returning value: String? = nil, throwing error: Error? = nil) { + private func finish( + returning value: [String: String]? = nil, + throwing error: Error? = nil + ) { lock.lock() let alreadyFinished = isFinished isFinished = true @@ -129,7 +250,7 @@ final class MetaBootProbe: NSObject, @unchecked Sendable { continuation.resume(returning: value) } else { continuation.resume(throwing: error ?? MWFirmwareError.operationFailed( - "Bootloader version read failed." + "MetaBoot Device Information read failed." )) } } @@ -146,7 +267,7 @@ extension MetaBootProbe: CBCentralManagerDelegate { guard central.state == .poweredOn else { if central.state != .unknown && central.state != .resetting { finish(throwing: MWFirmwareError.operationFailed( - "Bluetooth unavailable while reading the bootloader version." + "Bluetooth unavailable while reading Device Information from MetaBoot." )) } return @@ -155,7 +276,7 @@ extension MetaBootProbe: CBCentralManagerDelegate { // connection; retrieval works even before a fresh advertisement. guard let target = central.retrievePeripherals(withIdentifiers: [targetIdentifier]).first else { finish(throwing: MWFirmwareError.operationFailed( - "MetaBoot peripheral not found for bootloader version read." + "MetaBoot peripheral not found for Device Information read." )) return } @@ -185,8 +306,14 @@ extension MetaBootProbe: CBCentralManagerDelegate { didDisconnectPeripheral peripheral: CBPeripheral, error: Error? ) { + // If the connection dropped after all reads succeeded we've already + // finished — the `hasFinished` check would gate the throw. But the + // finish() re-check catches races: if the peripheral disconnected + // between the last didUpdateValueFor and our own cancelPeripheralConnection, + // finish() will no-op the second call. + guard !hasFinished else { return } finish(throwing: MWFirmwareError.operationFailed( - "MetaBoot disconnected during bootloader version read." + "MetaBoot disconnected during Device Information read." )) } } @@ -205,7 +332,7 @@ extension MetaBootProbe: CBPeripheralDelegate { )) return } - peripheral.discoverCharacteristics([Self.firmwareRevision], for: service) + peripheral.discoverCharacteristics(characteristics, for: service) } func peripheral( @@ -213,16 +340,34 @@ extension MetaBootProbe: CBPeripheralDelegate { didDiscoverCharacteristicsFor service: CBService, error: Error? ) { - guard error == nil, - let characteristic = service.characteristics?.first( - where: { $0.uuid == Self.firmwareRevision } - ) else { + guard error == nil, let discovered = service.characteristics else { + finish(throwing: MWFirmwareError.operationFailed( + "MetaBoot characteristic discovery failed: \(error?.localizedDescription ?? "unknown")" + )) + return + } + // Verify every requested characteristic is present. Missing a needed + // characteristic is a firmware/hardware mismatch we can't work + // around — surface it clearly instead of silently returning partial + // data. + let discoveredUUIDs = Set(discovered.map(\.uuid)) + let missing = Set(characteristics).subtracting(discoveredUUIDs) + if !missing.isEmpty { + finish(throwing: MWFirmwareError.operationFailed( + "MetaBoot is missing expected DIS characteristics: \(missing.map(\.uuidString).joined(separator: ", "))" + )) + return + } + // Kick off the first read; subsequent reads chain from + // didUpdateValueFor once the previous value lands. + guard let first = characteristics.first, + let firstChar = discovered.first(where: { $0.uuid == first }) else { finish(throwing: MWFirmwareError.operationFailed( - "MetaBoot exposes no Firmware Revision characteristic." + "MetaBoot characteristic list is empty." )) return } - peripheral.readValue(for: characteristic) + peripheral.readValue(for: firstChar) } func peripheral( @@ -230,16 +375,39 @@ extension MetaBootProbe: CBPeripheralDelegate { didUpdateValueFor characteristic: CBCharacteristic, error: Error? ) { + guard !hasFinished else { return } guard error == nil, let data = characteristic.value, - let version = String(data: data, encoding: .utf8)? + let value = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), - !version.isEmpty else { + !value.isEmpty else { + finish(throwing: MWFirmwareError.operationFailed( + "Unreadable value for \(characteristic.uuid.uuidString) from MetaBoot." + )) + return + } + lock.lock() + results[characteristic.uuid.uuidString] = value + characteristicsToRead.remove(characteristic.uuid) + let remaining = characteristicsToRead + let allResults = results + lock.unlock() + if remaining.isEmpty { + finish(returning: allResults) + return + } + // Find the next characteristic to read from the ordered list, + // skipping ones already read. Order matters because callers may + // want stable log output; the set difference alone doesn't preserve + // order. + guard let nextUUID = characteristics.first(where: { remaining.contains($0) }), + let service = peripheral.services?.first(where: { $0.uuid == Self.deviceInformationService }), + let nextChar = service.characteristics?.first(where: { $0.uuid == nextUUID }) else { finish(throwing: MWFirmwareError.operationFailed( - "Unreadable bootloader version value." + "MetaBoot next-characteristic lookup failed after reading \(characteristic.uuid.uuidString)." )) return } - finish(returning: version) + peripheral.readValue(for: nextChar) } } diff --git a/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift b/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift index 9505f6b..5976866 100644 --- a/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift +++ b/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift @@ -156,13 +156,13 @@ extension MetaWearDevice { localURL = zipURL } else { continuation.yield(DFUProgress(state: .downloadingFirmware)) - localURL = try await Self._downloadToLocal(zipURL, fetcher: fetcher) + localURL = try await DFUFlasher.downloadToLocal(zipURL, fetcher: fetcher) } // 3. Build the DFUFirmware (zip vs bin/hex) before tearing down BLE // so a parse failure bails out cleanly while we're still // connected. - let firmware = try Self._makeDFUFirmware(from: localURL) + let firmware = try DFUFlasher.makeDFUFirmware(from: localURL) // 4. Capture the identifier — we'll use it to address the // bootloader-mode peripheral once BLE drops. @@ -173,7 +173,7 @@ extension MetaWearDevice { // here — callers flashing custom firmware are expected to know // their board's bootloader. try await self._handoffToBootloader(continuation: continuation) - try await self._flashStages( + try await DFUFlasher.flashStages( [firmware], targetIdentifier: targetIdentifier, continuation: continuation @@ -212,101 +212,12 @@ extension MetaWearDevice { try? await Task.sleep(for: .milliseconds(1500)) } - /// Flash one or more firmware images in sequence — bootloader first when - /// the interlock demands it, then the application. Each stage is one - /// Nordic DFU run; after a bootloader stage the board resets back into - /// MetaBoot (there's no valid application to boot into yet). - /// - /// Progress from every stage is renumbered so observers see stage-level - /// `currentPart`/`totalParts`, and `.completed` is suppressed for all but - /// the final stage — only the whole sequence finishing means "done". - fileprivate func _flashStages( - _ stages: [DFUFirmware], - targetIdentifier: UUID, - continuation: AsyncThrowingStream.Continuation - ) async throws { - let total = stages.count - for (index, firmware) in stages.enumerated() { - // A cancelled task must never start (or continue past) a Nordic - // DFU pass — DFUSession.run fires initiator.start synchronously - // before iteration can observe the cancellation. - try Task.checkCancellation() - let stage = index + 1 - let isLast = stage == total - continuation.yield(DFUProgress( - state: .scanning, currentPart: stage, totalParts: total - )) - do { - try await _runDFUPass( - firmware: firmware, targetIdentifier: targetIdentifier, - stage: stage, of: total, isLast: isLast, - continuation: continuation - ) - } catch MWFirmwareError.dfuFailed(let message) - where message.contains("DFU Service not found") { - // Nordic lost the reconnect race (pre-reboot window / stale - // GATT). MetaBoot sits waiting after boot, so one delayed - // retry per stage is safe. - mwFirmwareLog("[DFU] service not found — retrying once after settle delay") - try? await Task.sleep(for: .seconds(2)) - continuation.yield(DFUProgress( - state: .scanning, currentPart: stage, totalParts: total - )) - try await _runDFUPass( - firmware: firmware, targetIdentifier: targetIdentifier, - stage: stage, of: total, isLast: isLast, - continuation: continuation - ) - } - if !isLast { - mwFirmwareLog("[DFU] stage \(stage)/\(total) flashed — waiting for reboot into MetaBoot") - // Throwing sleep on purpose: cancellation between stages must - // abort the sequence, not fall through into the next flash. - try await Task.sleep(for: .milliseconds(2500)) - } - } - } - - /// One Nordic DFU attempt. `DFUSession` is single-use, so each pass gets - /// a fresh session and queue. - fileprivate func _runDFUPass( - firmware: DFUFirmware, - targetIdentifier: UUID, - stage: Int, - of total: Int, - isLast: Bool, - continuation: AsyncThrowingStream.Continuation - ) async throws { - let session = DFUSession() - let queue = DispatchQueue( - label: "com.metawear.firmware.dfu.\(targetIdentifier.uuidString)", - qos: .userInitiated - ) - let dfuStream = session.run( - firmware: firmware, - targetIdentifier: targetIdentifier, - queue: queue - ) - for try await progress in dfuStream { - // A non-final stage finishing is progress, not completion. - if progress.state == .completed && !isLast { continue } - guard total > 1 else { - continuation.yield(progress) - continue - } - continuation.yield(DFUProgress( - state: progress.state, - percentComplete: progress.percentComplete, - currentPart: stage, - totalParts: total, - bytesPerSecond: progress.bytesPerSecond - )) - } - // AsyncThrowingStream's next() returns nil on task cancellation - // instead of throwing — a cancelled iteration must not read as - // stage success (the next stage would flash a half-written board). - if Task.isCancelled { throw MWFirmwareError.aborted } - } + // MARK: - Flashing extracted + // + // `flashStages`, `runDFUPass`, `makeDFUFirmware`, `downloadToLocal`, and + // `bootloaderStagesIfNeeded` moved to `DFUFlasher` so the MetaBoot-only + // update path can share them without a `MetaWearDevice` instance. See + // `DFUFlasher.swift`. /// Driver for `updateFirmwareToLatest(server:)`. /// @@ -352,7 +263,7 @@ extension MetaWearDevice { let applicationURL = try await server.downloadFirmware(build) // Parse before tearing down BLE so a bad artifact bails out while // the board is still in application mode. - let application = try Self._makeDFUFirmware(from: applicationURL) + let application = try DFUFlasher.makeDFUFirmware(from: applicationURL) try self._ensureFlashableState() let targetIdentifier = self.identifier @@ -360,128 +271,21 @@ extension MetaWearDevice { var stages: [DFUFirmware] = [] if build.requiredBootloader != nil { - stages = try await self._bootloaderStagesIfNeeded( + stages = try await DFUFlasher.bootloaderStagesIfNeeded( for: build, - deviceInfo: info, + hardwareRev: info.hardwareRevision, + modelNumber: info.modelNumber, server: server, targetIdentifier: targetIdentifier, continuation: continuation ) } stages.append(application) - try await self._flashStages( + try await DFUFlasher.flashStages( stages, targetIdentifier: targetIdentifier, continuation: continuation ) } - /// Read the installed bootloader from MetaBoot and, when it's older than - /// the build's requirement, download the chain of catalog bootloaders - /// that fixes it (bootloader builds declare requirements of their own, - /// so one upgrade may need stepping stones). - /// - /// A failed PROBE degrades to a single-stage flash (pre-interlock - /// behavior) rather than blocking the update: near-all boards in the - /// field already run an adequate bootloader, and a flaky characteristic - /// read shouldn't strand them. Cancellation is NOT a probe failure and - /// is rethrown — a cancelled update must never proceed to flash. A - /// confirmed-outdated bootloader with no catalog remedy throws - /// `bootloaderUpgradeUnavailable`; catalog fetch errors propagate as - /// themselves so a transient network blip doesn't masquerade as that - /// terminal verdict. - fileprivate func _bootloaderStagesIfNeeded( - for build: MWFirmwareBuild, - deviceInfo info: MWDeviceInformation, - server: MWFirmwareServer, - targetIdentifier: UUID, - continuation: AsyncThrowingStream.Continuation - ) async throws -> [DFUFirmware] { - let installed: String - do { - installed = try await MetaBootProbe.readBootloaderVersion( - identifier: targetIdentifier - ) - mwFirmwareLog("[DFU] MetaBoot reports bootloader \(installed)") - } catch is CancellationError { - throw CancellationError() - } catch MWFirmwareError.aborted { - throw MWFirmwareError.aborted - } catch { - mwFirmwareLog("[DFU] ⚠️ bootloader probe failed (\(error.localizedDescription)) — flashing application only") - return [] - } - let bootloaders = try await server.availableBuilds( - hardwareRev: info.hardwareRevision, - modelNumber: info.modelNumber, - buildFlavor: "bootloader" - ) - let plan = try BootloaderInterlock.plan( - installedBootloader: installed, - requiredBootloader: build.requiredBootloader, - availableBootloaders: bootloaders, - hardwareRev: info.hardwareRevision - ) - guard case .flashBootloadersFirst(let chain) = plan else { - return [] - } - mwFirmwareLog("[DFU] bootloader \(installed) < required \(build.requiredBootloader ?? "?") — staging \(chain.map(\.firmwareRev).joined(separator: " → "))") - let total = chain.count + 1 - var firmwares: [DFUFirmware] = [] - for (index, bootloaderBuild) in chain.enumerated() { - continuation.yield(DFUProgress( - state: .downloadingFirmware, - currentPart: index + 1, - totalParts: total - )) - let url = try await server.downloadFirmware(bootloaderBuild) - firmwares.append(try Self._makeDFUFirmware(from: url)) - } - return firmwares - } - - // MARK: - Helpers - - fileprivate static func _downloadToLocal( - _ url: URL, - fetcher: MWFirmwareFetcher - ) async throws -> URL { - let (tempURL, response) = try await fetcher.download(from: url) - guard (200..<300).contains(response.statusCode) else { - try? FileManager.default.removeItem(at: tempURL) - throw MWFirmwareError.badServerResponse(status: response.statusCode) - } - // Re-stage under the source's filename: `_makeDFUFirmware` dispatches - // on the extension, and the session's temp file ends in ".tmp", which - // would be rejected as an invalid firmware container. - return try MWFirmwareServer.stageDownload( - tempURL: tempURL, - filename: url.lastPathComponent - ) - } - - fileprivate static func _makeDFUFirmware(from url: URL) throws -> DFUFirmware { - let ext = url.pathExtension.lowercased() - do { - switch ext { - case "zip": - return try DFUFirmware(urlToZipFile: url) - case "bin", "hex": - return try DFUFirmware( - urlToBinOrHexFile: url, - urlToDatFile: nil, - type: .application - ) - default: - throw MWFirmwareError.invalidFirmwareFile(url) - } - } catch let mwError as MWFirmwareError { - throw mwError - } catch { - // Nordic's parser throws its own errors (zip extraction failure, - // missing manifest, etc.). Wrap them so callers see one error - // taxonomy. - throw MWFirmwareError.invalidFirmwareFile(url) - } - } } diff --git a/Tests/MetaWearTests/MetaBootAdmissionTests.swift b/Tests/MetaWearTests/MetaBootAdmissionTests.swift new file mode 100644 index 0000000..21e0baf --- /dev/null +++ b/Tests/MetaWearTests/MetaBootAdmissionTests.swift @@ -0,0 +1,108 @@ +// +// MetaBootAdmissionTests.swift +// MetaWearTests +// +// Mirror of `ScannerAdmissionTests` for the MetaBoot (bootloader) admission +// rule. The two predicates are disjoint by design — an advertisement matches +// one, the other, or neither, never both — so the two suites together cover +// the scanner's routing gate. +// + +import Foundation +import Testing +@testable import MetaWear + +@Suite("MetaBoot admission") +struct MetaBootAdmissionTests { + + private let nordicDFUService = "00001530-1212-EFDE-1523-785FEABCD123" + private let metaWearService = "326A9000-85CB-9195-D9DD-464CFBBAE75A" + + // MARK: - Admission signals + + @Test + func admitsDefaultName() { + // MetaBoot's canonical local name. + #expect(MetaWearScanner.isMetaBootAdvertisement( + name: "MetaBoot", serviceUUIDs: [] + )) + } + + @Test + func admitsByNordicDFUServiceUUID() { + // A MetaBoot with a customised name should still be detectable via + // the Nordic DFU service in its advertised service list. + #expect(MetaWearScanner.isMetaBootAdvertisement( + name: "bob", serviceUUIDs: [nordicDFUService] + )) + } + + @Test + func serviceUUIDComparisonIsCaseInsensitive() { + #expect(MetaWearScanner.isMetaBootAdvertisement( + name: "bob", serviceUUIDs: [nordicDFUService.lowercased()] + )) + } + + // MARK: - Rejection signals + + @Test + func rejectsApplicationModeBoard() { + // The default MetaWear ad — service UUID matches the MetaWear + // command service, name prefix "MetaWear". Must NOT surface as a + // MetaBoot device or the scanner's routing gate is broken. + #expect(!MetaWearScanner.isMetaBootAdvertisement( + name: "MetaWear", serviceUUIDs: [metaWearService] + )) + } + + @Test + func rejectsRenamedApplicationModeBoard() { + // Same as above but the operator renamed the board to something + // that could be mistaken for a bootloader on a lazy substring match. + // The predicate demands EXACT name equality, not `hasPrefix`. + #expect(!MetaWearScanner.isMetaBootAdvertisement( + name: "MetaBoot-clone", serviceUUIDs: [metaWearService] + )) + } + + @Test + func rejectsForeignPeripherals() { + #expect(!MetaWearScanner.isMetaBootAdvertisement( + name: "AirPods Pro", serviceUUIDs: ["FE59", "180F"] + )) + #expect(!MetaWearScanner.isMetaBootAdvertisement( + name: "", serviceUUIDs: [] + )) + } + + // MARK: - Disjoint from MetaWear admission + + @Test + func mutuallyExclusiveWithMetaWearAdmission() { + // Every advertisement matches AT MOST one predicate. This is the + // invariant the scanner's routing gate depends on — if both were + // true a device would appear in both discovery buckets. + let candidates: [(name: String, services: [String], mfg: Data?)] = [ + ("MetaWear", [metaWearService], nil), + ("bob", [metaWearService], nil), + ("MetaBoot", [], nil), + ("bob", [nordicDFUService], nil), + ("", [], nil), + ("AirPods", ["FE59"], nil), + ] + for candidate in candidates { + let asMetaWear = MetaWearScanner.isMetaWearAdvertisement( + name: candidate.name, + serviceUUIDs: candidate.services, + manufacturerData: candidate.mfg + ) + let asMetaBoot = MetaWearScanner.isMetaBootAdvertisement( + name: candidate.name, + serviceUUIDs: candidate.services + ) + #expect(!(asMetaWear && asMetaBoot), + "Advertisement '\(candidate.name)' \(candidate.services) matched BOTH admission predicates — the routing gate would double-vend it.") + } + } +}