From 341343a81f5bc2cc5e38512a6b0f82f4380f9b8b Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 18:18:22 -0700 Subject: [PATCH 01/13] Show live RSSI for the connected board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boards stop advertising once connected, so advertisement RSSI freezes the moment a connection succeeds — and the app showed no signal strength at all for a connected board. ScannerViewModel had a fully built beginRSSIPolling that nothing ever called. Polling now lives in AppStore, which owns the connection lifecycle: readRSSI() every 3 s into connectedRSSI, started on successful connect and stopped by every disconnect path. The scan row's Connected badge carries live bars + dBm, and Device Info gains a Signal row next to Battery. The dead ScannerViewModel polling is removed. Co-Authored-By: Claude Opus 4.8 --- Apps/MetaWear/MetaWear/App/AppStore.swift | 34 +++++++++++++++++++ .../Components/DeviceStatusBadge.swift | 27 +++++++++++++-- .../Features/DeviceInfo/DeviceInfoView.swift | 12 +++++++ .../MetaWear/Features/Scan/ScanView.swift | 2 +- .../ViewModels/ScannerViewModel.swift | 32 +++-------------- 5 files changed, 75 insertions(+), 32 deletions(-) diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index 24b52d0..7472457 100644 --- a/Apps/MetaWear/MetaWear/App/AppStore.swift +++ b/Apps/MetaWear/MetaWear/App/AppStore.swift @@ -30,6 +30,13 @@ final class AppStore { var connectionState: DeviceState = .disconnected var lastError: AppError? + /// Live RSSI of the active connection, polled every 3 s. `nil` when + /// disconnected or before the first read completes. Boards stop + /// advertising once connected, so `scanner.advertisementRSSI` freezes — + /// this poll is the only live signal source for a connected board. + private(set) var connectedRSSI: Int? + @ObservationIgnored private var rssiPollTask: Task? + var rememberedDevices: [RememberedDevice] = [] var pendingLogSessions: [LogSessionRecord] = [] @@ -115,6 +122,7 @@ final class AppStore { } connectionState = await device.state connectingDeviceID = nil + startRSSIPolling(for: device) await rememberDevice(device) } catch { // Same staleness rule on the failure path: only reset shared @@ -382,6 +390,7 @@ final class AppStore { private func handleUnexpectedDisconnect(deviceID: UUID, error: Error) { // Ignore stale callbacks from a device we've since moved away from. guard activeDeviceID == deviceID else { return } + stopRSSIPolling() connectionState = .disconnected activeDevice = nil activeDeviceID = nil @@ -391,12 +400,37 @@ final class AppStore { func disconnect() async { guard let device = activeDevice else { return } + stopRSSIPolling() try? await device.disconnect() connectionState = .disconnected activeDevice = nil activeDeviceID = nil } + // MARK: - Connected-RSSI polling + + private func startRSSIPolling(for device: MetaWearDevice) { + stopRSSIPolling() + rssiPollTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + guard let self, self.activeDeviceID == device.identifier else { return } + // Best-effort: a failed read (mid-teardown, DFU handoff) + // just leaves the last value; the poller is cancelled by + // every disconnect path. + if let rssi = try? await device.readRSSI(), !Task.isCancelled { + self.connectedRSSI = rssi + } + try? await Task.sleep(for: .seconds(3)) + } + } + } + + private func stopRSSIPolling() { + rssiPollTask?.cancel() + rssiPollTask = nil + connectedRSSI = nil + } + func forget(_ remembered: RememberedDevice) { let context = containers.cloud.mainContext context.delete(remembered) diff --git a/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift b/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift index f047031..4969999 100644 --- a/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift +++ b/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift @@ -1,7 +1,7 @@ import SwiftUI enum DeviceConnectionStatus: Equatable { - case connected + case connected(rssi: Int?) case connecting case available(rssi: Int?) case offline @@ -12,8 +12,29 @@ struct DeviceStatusBadge: View { var body: some View { switch status { - case .connected: - label("Connected", systemImage: "circle.fill", tint: Palette.success) + case .connected(let rssi): + // Live link quality rides inside the badge: boards stop + // advertising once connected, so this RSSI comes from the + // AppStore's connection poll, not from advertisements. + HStack(spacing: 6) { + Image(systemName: "circle.fill") + .font(.caption2) + .foregroundStyle(Palette.success) + Text("Connected") + .font(.caption.weight(.medium)) + .foregroundStyle(Palette.success) + if let rssi { + RSSIBars(dBm: rssi) + Text("\(rssi) dBm") + .font(.caption2.monospaced()) + .foregroundStyle(Palette.success) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Palette.success.opacity(0.15))) + .accessibilityElement(children: .combine) + .accessibilityLabel(rssi.map { "Connected, signal \($0) dBm" } ?? "Connected") case .connecting: HStack(spacing: 6) { ProgressView() diff --git a/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift b/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift index 1952b43..3a6e3fe 100644 --- a/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift +++ b/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift @@ -20,6 +20,18 @@ struct DeviceInfoView: View { LabeledContent("Battery") { BatteryPill(battery: viewModel.battery) } + LabeledContent("Signal") { + if let rssi = appStore.connectedRSSI { + HStack(spacing: 6) { + RSSIBars(dBm: rssi) + Text("\(rssi) dBm") + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + } else { + Text("—").foregroundStyle(.secondary) + } + } } // List every module the SDK knows about. Present modules diff --git a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift index 4aa3f4e..7dda9cf 100644 --- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift +++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift @@ -173,7 +173,7 @@ struct ScanView: View { if appStore.activeDeviceID == uuid, appStore.connectionState != .disconnected, appStore.connectingDeviceID != uuid { - return .connected + return .connected(rssi: appStore.connectedRSSI) } if appStore.connectingDeviceID == uuid { return .connecting diff --git a/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift index 139f3d4..41d0402 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/ScannerViewModel.swift @@ -10,8 +10,6 @@ import MetaWear @MainActor final class ScannerViewModel { private let scanner: MetaWearScanner - var rssi: [UUID: Int] = [:] - private var rssiTasks: [UUID: Task] = [:] init(scanner: MetaWearScanner) { self.scanner = scanner @@ -29,35 +27,13 @@ final class ScannerViewModel { } func startScan() { scanner.startScan() } - func stopScan() { - scanner.stopScan() - rssiTasks.values.forEach { $0.cancel() } - rssiTasks.removeAll() - } + func stopScan() { scanner.stopScan() } func advertisedName(for id: UUID) -> String? { scanner.advertisedNames[id] } - func beginRSSIPolling(for device: MetaWearDevice) { - let id = device.identifier - rssiTasks[id]?.cancel() - rssiTasks[id] = Task { @MainActor [weak self] in - while !Task.isCancelled { - do { - let value = try await device.readRSSI() - guard !Task.isCancelled else { return } - self?.rssi[id] = value - } catch { - return - } - try? await Task.sleep(for: .seconds(3)) - } - } - } - - func stopRSSIPolling(for id: UUID) { - rssiTasks[id]?.cancel() - rssiTasks.removeValue(forKey: id) - } + // 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. } From 092529c4bff86c35b9deed8c2df500bc66c438ac Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 18:18:22 -0700 Subject: [PATCH 02/13] Rename refreshes the MAC-broadcast name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan response freezes its embedded name at configuration time (documented limitation of the MAC broadcast), so a renamed board kept advertising its old name indefinitely. - SDK: MetaWearDevice.updateMACAdvertisement(advertisedName:) re-issues the broadcast WITHOUT stacking macros — the Macro module has no per-macro erase, so it erases all macros and re-records the one this SDK owns (documented: the MAC broadcast is treated as the board's sole macro; custom-macro users must re-record). - App: after a successful rename of a board this app configured (macAdvertisementConfigured gate), the broadcast is refreshed with the new name. Best-effort — a failure leaves the old name on air until the next reconfiguration and never blocks the rename. Co-Authored-By: Claude Opus 4.8 --- .../MetaWear/ViewModels/DeviceViewModel.swift | 17 +++++++++++++++++ .../MetaWear/Models/MWMACAdvertisement.swift | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift index f2001f6..98441cf 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift @@ -1,6 +1,7 @@ import Foundation import Observation import MetaWear +import os /// Presentation model for the connected-device overview. /// @@ -71,11 +72,27 @@ final class DeviceViewModel { func rename(to newName: String) async { do { try await device.send(MWSettings.SetDeviceName(validating: newName)) + await refreshMACBroadcastAfterRename(newName) } catch { lastError = AppError(error: error) } } + /// The MAC-broadcast scan response freezes its embedded name at + /// configuration time — keep it in sync after a rename on boards this + /// app configured. Best-effort: a failure leaves the OLD name on air + /// until the next reconfiguration; it never blocks the rename itself. + private func refreshMACBroadcastAfterRename(_ newName: String) async { + guard let mac = macAddress, + appStore.macAdvertisementConfigured.contains(mac) else { return } + do { + try await device.updateMACAdvertisement(advertisedName: newName) + AppStore.log.info("Refreshed MAC broadcast name for \(mac, privacy: .public)") + } catch { + AppStore.log.error("MAC broadcast rename refresh failed for \(mac, privacy: .public): \(error, privacy: .public)") + } + } + func identify() async { do { try await device.send(MWLED.SetPattern(color: .green, .flash)) diff --git a/Sources/MetaWear/Models/MWMACAdvertisement.swift b/Sources/MetaWear/Models/MWMACAdvertisement.swift index ff746c1..2060647 100644 --- a/Sources/MetaWear/Models/MWMACAdvertisement.swift +++ b/Sources/MetaWear/Models/MWMACAdvertisement.swift @@ -127,4 +127,18 @@ public extension MetaWearDevice { try await send(command) return macro } + + /// Re-issue the MAC broadcast with a new advertised name (e.g. after + /// `MWSettings.SetDeviceName`) **without stacking macros**. + /// + /// The Macro module offers no per-macro erase — only `ERASE_ALL` — so + /// this erases every stored macro before re-recording the broadcast + /// macro. This SDK treats the MAC broadcast as the board's sole macro + /// owner; if a caller records custom macros alongside it, they must be + /// re-recorded after calling this. + @discardableResult + func updateMACAdvertisement(advertisedName: String) async throws -> MWMacro { + try await eraseAllMacros() + return try await enableMACAdvertisement(advertisedName: advertisedName) + } } From 2b6092d3bdf805279f0d33e35e6516f8e174a770 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 18:25:32 -0700 Subject: [PATCH 03/13] Allow reinstalling the latest firmware on an up-to-date board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery path for a board misbehaving on current firmware: Settings -> Firmware's "Up to date" state gains a Reinstall Firmware button (its own confirmation, spelling out that on-board logs/macros/settings are erased). SDK: updateFirmwareToLatest(forceReinstall:) fetches latestBuild directly instead of the newer-than gate; everything downstream (bootloader interlock, staged flash, retry) applies as-is. Because a flash wipes on-board macros, a successful update (forced or normal) now re-establishes the MAC-broadcast macro on boards this host configured — via the erase-and-re-record path so the outcome is one macro regardless of what survived — or boards would turn anonymous again after their next power cycle. Co-Authored-By: Claude Opus 4.8 --- .../Settings/DeviceSettingsView.swift | 16 +++++++++++ .../ViewModels/FirmwareUpdateViewModel.swift | 28 +++++++++++++++++-- .../MetaWearFirmware/MetaWearDevice+DFU.swift | 22 ++++++++++++--- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index 6bebf16..0d9fe37 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -161,6 +161,7 @@ struct DeviceSettingsView: View { private struct FirmwareSection: View { let viewModel: FirmwareUpdateViewModel @State private var showUpdateConfirm = false + @State private var showReinstallConfirm = false var body: some View { Section { @@ -185,6 +186,16 @@ private struct FirmwareSection: View { } message: { Text("Keep MetaWear open with the board nearby and powered until the update finishes. The board restarts automatically when it's done.") } + .confirmationDialog("Reinstall current firmware?", + isPresented: $showReinstallConfirm, + titleVisibility: .visible) { + Button("Reinstall", role: .destructive) { + Task { await viewModel.startUpdate(forceReinstall: true) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Reflashes the latest firmware even though the board is already up to date. On-board logs, macros, and settings are erased. Keep MetaWear open with the board nearby and powered until it finishes.") + } } @ViewBuilder @@ -212,6 +223,11 @@ private struct FirmwareSection: View { Button("Check Again", systemImage: "arrow.triangle.2.circlepath") { Task { await viewModel.checkForUpdate() } } + // Recovery path: reflash the current firmware on a board that's + // misbehaving despite being up to date. + Button("Reinstall Firmware", systemImage: "arrow.counterclockwise.circle") { + showReinstallConfirm = true + } case .updateAvailable(let build): LabeledContent("Latest") { diff --git a/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift index 501260d..dde932e 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift @@ -2,6 +2,7 @@ import Foundation import Observation import MetaWear import MetaWearFirmware +import os /// Presentation model for the Settings → Firmware section. /// @@ -88,7 +89,9 @@ final class FirmwareUpdateViewModel { /// dialog), so `.disconnected` gets one automatic reconnect attempt before /// giving up — and its failure message says "reconnect", not the /// misleading busy-device text. - func startUpdate() async { + /// - Parameter forceReinstall: Flash the latest catalog build even when + /// the board already runs it — a recovery path for a misbehaving board. + func startUpdate(forceReinstall: Bool = false) async { if case .disconnected = await device.state { phase = .checking await appStore.connect(to: device) @@ -107,7 +110,9 @@ final class FirmwareUpdateViewModel { var sawCompleted = false do { - for try await progress in device.updateFirmwareToLatest() { + for try await progress in device.updateFirmwareToLatest( + forceReinstall: forceReinstall + ) { phase = .updating(progress) if progress.state == .completed { sawCompleted = true } } @@ -130,10 +135,29 @@ final class FirmwareUpdateViewModel { } await reconnect() + await restoreMACBroadcast() await loadCurrentVersion() phase = .completed } + /// A firmware flash wipes on-board macros — including the MAC-broadcast + /// macro this app records. Re-establish it after a successful update on + /// boards this host configured, or they'd turn anonymous again after + /// their next power cycle. Uses the erase-and-re-record path so the + /// outcome is one macro regardless of what survived the flash. + /// Best-effort: the next connect's configuration gate is the backstop. + private func restoreMACBroadcast() async { + guard let mac = try? await device.read(MWSettings.ReadMacAddress()).value, + appStore.macAdvertisementConfigured.contains(mac) else { return } + do { + let name = appStore.scanner.advertisedNames[device.identifier] ?? "MetaWear" + try await device.updateMACAdvertisement(advertisedName: name) + AppStore.log.info("Restored MAC broadcast after firmware flash for \(mac, privacy: .public)") + } catch { + AppStore.log.error("MAC broadcast restore after flash failed for \(mac, privacy: .public): \(error, privacy: .public)") + } + } + /// Re-establish a coherent connection after the board reboots out of DFU. /// /// The flash path disconnects the board internally, so `AppStore`'s diff --git a/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift b/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift index def5ecb..9505f6b 100644 --- a/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift +++ b/Sources/MetaWearFirmware/MetaWearDevice+DFU.swift @@ -103,9 +103,12 @@ public extension MetaWearDevice { /// Fetch the latest firmware from MbientLab's release catalog and flash /// it. If the device is already on the latest, the stream finishes with - /// no events. + /// no events — unless `forceReinstall` is set, which flashes the latest + /// build regardless (recovery path for a misbehaving board, or a clean + /// reflash after experimentation). nonisolated func updateFirmwareToLatest( - server: MWFirmwareServer = MWFirmwareServer() + server: MWFirmwareServer = MWFirmwareServer(), + forceReinstall: Bool = false ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { [weak self] in @@ -118,6 +121,7 @@ public extension MetaWearDevice { do { try await self._runUpdateToLatest( server: server, + forceReinstall: forceReinstall, continuation: continuation ) continuation.finish() @@ -314,6 +318,7 @@ extension MetaWearDevice { /// the application), surfaced through `currentPart`/`totalParts`. fileprivate func _runUpdateToLatest( server: MWFirmwareServer, + forceReinstall: Bool, continuation: AsyncThrowingStream.Continuation ) async throws { guard let info = self.deviceInfo else { @@ -323,11 +328,20 @@ extension MetaWearDevice { } continuation.yield(DFUProgress(state: .fetchingCatalog)) - guard let build = try await server.updateAvailable( + let build: MWFirmwareBuild + if forceReinstall { + // Flash the latest build even when the board already runs it. + build = try await server.latestBuild( + hardwareRev: info.hardwareRevision, + modelNumber: info.modelNumber + ) + } else if let update = try await server.updateAvailable( currentRev: info.firmwareRevision, hardwareRev: info.hardwareRevision, modelNumber: info.modelNumber - ) else { + ) { + build = update + } else { // Already up to date. Finish with no events; caller can // distinguish "nothing to do" from "update completed" by // observing whether `.completed` was yielded. From 9c0979ab178b6cbcace83d26cb56d968965c71e2 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 20:21:59 -0700 Subject: [PATCH 04/13] Slim the Connected badge: green bars, no dBm text, never wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field feedback: the badge with "Connected" + bars + dBm overflowed the scan row onto two lines, and the bars rendered in the accent blue next to the green text. The badge now shows dot + "Connected" + bars tinted to match (RSSIBars gains a tint parameter, defaulting to the accent so other call sites are unchanged), with fixedSize so it can never wrap — the precise dBm reading lives in Device Info's Signal row. Co-Authored-By: Claude Opus 4.8 --- .../Designs/Components/DeviceStatusBadge.swift | 12 +++++++----- .../MetaWear/Designs/Components/RSSIBars.swift | 5 ++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift b/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift index 4969999..c122228 100644 --- a/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift +++ b/Apps/MetaWear/MetaWear/Designs/Components/DeviceStatusBadge.swift @@ -15,7 +15,10 @@ struct DeviceStatusBadge: View { case .connected(let rssi): // Live link quality rides inside the badge: boards stop // advertising once connected, so this RSSI comes from the - // AppStore's connection poll, not from advertisements. + // AppStore's connection poll, not from advertisements. Bars + // only, tinted to match the badge — the precise dBm lives in + // Device Info's Signal row, and the extra text made the badge + // wrap to two lines in the scan row. HStack(spacing: 6) { Image(systemName: "circle.fill") .font(.caption2) @@ -24,12 +27,11 @@ struct DeviceStatusBadge: View { .font(.caption.weight(.medium)) .foregroundStyle(Palette.success) if let rssi { - RSSIBars(dBm: rssi) - Text("\(rssi) dBm") - .font(.caption2.monospaced()) - .foregroundStyle(Palette.success) + RSSIBars(dBm: rssi, tint: Palette.success) } } + .lineLimit(1) + .fixedSize() .padding(.horizontal, 8) .padding(.vertical, 4) .background(Capsule().fill(Palette.success.opacity(0.15))) diff --git a/Apps/MetaWear/MetaWear/Designs/Components/RSSIBars.swift b/Apps/MetaWear/MetaWear/Designs/Components/RSSIBars.swift index 1d9ea6b..df560fa 100644 --- a/Apps/MetaWear/MetaWear/Designs/Components/RSSIBars.swift +++ b/Apps/MetaWear/MetaWear/Designs/Components/RSSIBars.swift @@ -2,6 +2,9 @@ import SwiftUI struct RSSIBars: View { let dBm: Int? + /// Fill for the active bars. Defaults to the accent used in scan rows; + /// the connected badge passes its green so the bars match the badge. + var tint: Color = Palette.accent private var bars: Int { guard let dBm else { return 0 } @@ -17,7 +20,7 @@ struct RSSIBars: View { HStack(spacing: 2) { ForEach(0..<4) { index in Capsule() - .fill(index < bars ? Palette.accent : Color.secondary.opacity(0.3)) + .fill(index < bars ? tint : Color.secondary.opacity(0.3)) .frame(width: 3, height: 6 + CGFloat(index) * 3) } } From bde6e68c3a651967ea0576ecc42ef06be615a510 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 20:30:11 -0700 Subject: [PATCH 05/13] Show live signal in the device header (MMS/MMRL short names make room) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connected-device header's pill row (battery + model + firmware) had no space for the new live RSSI. MWModel gains shortName ("MMS", "MMRL") for space-constrained UI; the header uses it and adds a signal pill — bars + dBm from AppStore's connection poll — after the firmware tag. Co-Authored-By: Claude Opus 4.8 --- .../Features/DeviceDetail/DeviceDetailView.swift | 13 ++++++++++++- Sources/MetaWear/Models/MWModel.swift | 9 +++++++++ Tests/MetaWearTests/MWModelTests.swift | 10 ++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift index 955b366..86f7a87 100644 --- a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift +++ b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift @@ -75,7 +75,9 @@ struct DeviceDetailView: View { HStack(spacing: 8) { BatteryPill(battery: vm.battery) if let info = vm.deviceInfo { - Text(info.model.name) + // Short model names (MMS / MMRL) make room for the + // live signal pill alongside battery + firmware. + Text(info.model.shortName) .font(.metricCaption) .foregroundStyle(Palette.accent) .glassPill() @@ -84,6 +86,15 @@ struct DeviceDetailView: View { .foregroundStyle(.secondary) .glassPill() } + if let rssi = appStore.connectedRSSI { + HStack(spacing: 4) { + RSSIBars(dBm: rssi) + Text("\(rssi) dBm") + .font(.metricCaption.monospaced()) + .foregroundStyle(.secondary) + } + .glassPill() + } } } .glassCard() diff --git a/Sources/MetaWear/Models/MWModel.swift b/Sources/MetaWear/Models/MWModel.swift index bfb9188..c18631b 100644 --- a/Sources/MetaWear/Models/MWModel.swift +++ b/Sources/MetaWear/Models/MWModel.swift @@ -53,6 +53,15 @@ public enum MWModel: Sendable, Equatable { } } + /// Compact label for space-constrained UI (device headers, pills). + public var shortName: String { + switch self { + case .motionRL: return "MMRL" + case .motionS: return "MMS" + case .unknown(let n): return n + } + } + // MARK: Capability /// MMS has larger on-board flash and requires `flush_page` before log download. diff --git a/Tests/MetaWearTests/MWModelTests.swift b/Tests/MetaWearTests/MWModelTests.swift index ce226f7..faf9671 100644 --- a/Tests/MetaWearTests/MWModelTests.swift +++ b/Tests/MetaWearTests/MWModelTests.swift @@ -123,3 +123,13 @@ struct MWModelTests { #expect(info.isHardwareRevisionSupported == false) } } + +@Suite("MWModel short names") +struct MWModelShortNameTests { + @Test + func shortNamesAreCompactAbbreviations() { + #expect(MWModel.motionS.shortName == "MMS") + #expect(MWModel.motionRL.shortName == "MMRL") + #expect(MWModel.unknown(modelNumber: "3").shortName == "3") + } +} From c4c0085028a28be00e732b769e476758ce6d6ff0 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 20:38:57 -0700 Subject: [PATCH 06/13] Revert "Show live signal in the device header (MMS/MMRL short names make room)" This reverts commit bde6e68c3a651967ea0576ecc42ef06be615a510. --- .../Features/DeviceDetail/DeviceDetailView.swift | 13 +------------ Sources/MetaWear/Models/MWModel.swift | 9 --------- Tests/MetaWearTests/MWModelTests.swift | 10 ---------- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift index 86f7a87..955b366 100644 --- a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift +++ b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift @@ -75,9 +75,7 @@ struct DeviceDetailView: View { HStack(spacing: 8) { BatteryPill(battery: vm.battery) if let info = vm.deviceInfo { - // Short model names (MMS / MMRL) make room for the - // live signal pill alongside battery + firmware. - Text(info.model.shortName) + Text(info.model.name) .font(.metricCaption) .foregroundStyle(Palette.accent) .glassPill() @@ -86,15 +84,6 @@ struct DeviceDetailView: View { .foregroundStyle(.secondary) .glassPill() } - if let rssi = appStore.connectedRSSI { - HStack(spacing: 4) { - RSSIBars(dBm: rssi) - Text("\(rssi) dBm") - .font(.metricCaption.monospaced()) - .foregroundStyle(.secondary) - } - .glassPill() - } } } .glassCard() diff --git a/Sources/MetaWear/Models/MWModel.swift b/Sources/MetaWear/Models/MWModel.swift index c18631b..bfb9188 100644 --- a/Sources/MetaWear/Models/MWModel.swift +++ b/Sources/MetaWear/Models/MWModel.swift @@ -53,15 +53,6 @@ public enum MWModel: Sendable, Equatable { } } - /// Compact label for space-constrained UI (device headers, pills). - public var shortName: String { - switch self { - case .motionRL: return "MMRL" - case .motionS: return "MMS" - case .unknown(let n): return n - } - } - // MARK: Capability /// MMS has larger on-board flash and requires `flush_page` before log download. diff --git a/Tests/MetaWearTests/MWModelTests.swift b/Tests/MetaWearTests/MWModelTests.swift index faf9671..ce226f7 100644 --- a/Tests/MetaWearTests/MWModelTests.swift +++ b/Tests/MetaWearTests/MWModelTests.swift @@ -123,13 +123,3 @@ struct MWModelTests { #expect(info.isHardwareRevisionSupported == false) } } - -@Suite("MWModel short names") -struct MWModelShortNameTests { - @Test - func shortNamesAreCompactAbbreviations() { - #expect(MWModel.motionS.shortName == "MMS") - #expect(MWModel.motionRL.shortName == "MMRL") - #expect(MWModel.unknown(modelNumber: "3").shortName == "3") - } -} From 0318cdcf8f37307124100993147eb22b31d0b5e3 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 20:39:55 -0700 Subject: [PATCH 07/13] Live signal pill in the device header's top row, scan-screen blue Second placement attempt after reverting the pill-row version: the signal now sits in the header's TOP row beside the state pill, where there's free width, as a reusable RSSIPill using the same blue capsule styling as the scan screen's "available" badge (bars + dBm, info tint). Co-Authored-By: Claude Opus 4.8 --- .../Designs/Components/RSSIPill.swift | 23 +++++++++++++++++++ .../DeviceDetail/DeviceDetailView.swift | 5 ++++ 2 files changed, 28 insertions(+) create mode 100644 Apps/MetaWear/MetaWear/Designs/Components/RSSIPill.swift diff --git a/Apps/MetaWear/MetaWear/Designs/Components/RSSIPill.swift b/Apps/MetaWear/MetaWear/Designs/Components/RSSIPill.swift new file mode 100644 index 0000000..29a0915 --- /dev/null +++ b/Apps/MetaWear/MetaWear/Designs/Components/RSSIPill.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Live signal-strength pill: bars + dBm in the same blue capsule styling as +/// the scan screen's "available" badge, for showing a connected board's link +/// quality (fed by `AppStore.connectedRSSI` — boards stop advertising once +/// connected, so this value comes from the connection poll). +struct RSSIPill: View { + let dBm: Int + + var body: some View { + HStack(spacing: 6) { + RSSIBars(dBm: dBm) + Text("\(dBm) dBm") + .font(.caption2.monospaced()) + .foregroundStyle(Palette.info) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Palette.info.opacity(0.12))) + .accessibilityElement(children: .combine) + .accessibilityLabel("Signal \(dBm) dBm") + } +} diff --git a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift index 955b366..5bfcde1 100644 --- a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift +++ b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift @@ -69,6 +69,11 @@ struct DeviceDetailView: View { Text(vm.macAddress ?? "—") .font(.title3.weight(.semibold).monospaced()) Spacer() + // Live link quality sits beside the state pill — the + // top row has the free width, unlike the pill row below. + if let rssi = appStore.connectedRSSI { + RSSIPill(dBm: rssi) + } StatePill(state: appStore.connectionState, isLogging: isDeviceLogging) } From dd77783f20e8843526abb2b159cf5bc9f920e7da Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 20:45:29 -0700 Subject: [PATCH 08/13] Keep the header MAC on one line beside the signal and state pills Drops it from title3 to callout and lets it shrink to 70% on narrow phones instead of wrapping. Co-Authored-By: Claude Opus 4.8 --- .../MetaWear/Features/DeviceDetail/DeviceDetailView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift index 5bfcde1..9c5f1a5 100644 --- a/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift +++ b/Apps/MetaWear/MetaWear/Features/DeviceDetail/DeviceDetailView.swift @@ -66,8 +66,13 @@ struct DeviceDetailView: View { GlassEffectContainer { VStack(alignment: .leading, spacing: 12) { HStack { + // Sized to share the row with the signal + state pills: + // one line always, shrinking slightly on narrow phones + // rather than wrapping. Text(vm.macAddress ?? "—") - .font(.title3.weight(.semibold).monospaced()) + .font(.callout.weight(.semibold).monospaced()) + .lineLimit(1) + .minimumScaleFactor(0.7) Spacer() // Live link quality sits beside the state pill — the // top row has the free width, unlike the pill row below. From e8d0e95570d8997ca7ef09985443bc43eafa3f4d Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 20:58:44 -0700 Subject: [PATCH 09/13] Device Info: Battery and Signal rows match the plain-row anatomy The BatteryPill capsule and the custom Signal HStack made those two rows taller than the surrounding LabeledContent value rows, breaking the section's rhythm. Both are now plain value rows ('82%', '-48 dBm'); the richer pill treatments stay in the device header card. Co-Authored-By: Claude Opus 4.8 --- .../Features/DeviceInfo/DeviceInfoView.swift | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift b/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift index 3a6e3fe..46e5ac6 100644 --- a/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift +++ b/Apps/MetaWear/MetaWear/Features/DeviceInfo/DeviceInfoView.swift @@ -17,21 +17,15 @@ struct DeviceInfoView: View { if let mac = viewModel.macAddress { LabeledContent("MAC", value: mac).font(.body.monospaced()) } - LabeledContent("Battery") { - BatteryPill(battery: viewModel.battery) - } - LabeledContent("Signal") { - if let rssi = appStore.connectedRSSI { - HStack(spacing: 6) { - RSSIBars(dBm: rssi) - Text("\(rssi) dBm") - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - } else { - Text("—").foregroundStyle(.secondary) - } - } + // Plain value rows, deliberately matching the anatomy of + // the text rows above so every row in the section has + // identical height and separation. (The richer + // BatteryPill / RSSIPill treatments live in the device + // header card, where they belong.) + LabeledContent("Battery", + value: viewModel.battery.map { "\($0.charge)%" } ?? "—") + LabeledContent("Signal", + value: appStore.connectedRSSI.map { "\($0) dBm" } ?? "—") } // List every module the SDK knows about. Present modules From 042e77decf3d6db0cfbe7c34dda5ad1686743da5 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 21:15:19 -0700 Subject: [PATCH 10/13] Present destructive confirms as centered alerts, not anchored dialogs confirmationDialog renders as a popover anchored to the triggering control on iPad/Mac and lands in odd places; alert centers on screen on every platform. Converts all four settings-screen confirms (factory reset, clear logs, update firmware, reinstall firmware) with identical titles, messages, and button roles. Co-Authored-By: Claude Opus 4.8 --- .../Settings/DeviceSettingsView.swift | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index 0d9fe37..927dee7 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -83,9 +83,11 @@ struct DeviceSettingsView: View { } await refreshLogStats() } - .confirmationDialog("Factory reset this MetaWear?", - isPresented: $showFactoryResetConfirm, - titleVisibility: .visible) { + // Alerts, not confirmation dialogs, for every destructive confirm: + // dialogs anchor to the triggering control (a popover on iPad/Mac) + // and land in odd places — alerts center on screen everywhere. + .alert("Factory reset this MetaWear?", + isPresented: $showFactoryResetConfirm) { Button("Reset", role: .destructive) { Task { await viewModel?.factoryReset() } } @@ -93,9 +95,8 @@ struct DeviceSettingsView: View { } message: { Text("This will erase all on-device state. The board will reboot and disconnect.") } - .confirmationDialog("Clear all log data and loggers?", - isPresented: $showClearLogsConfirm, - titleVisibility: .visible) { + .alert("Clear all log data and loggers?", + isPresented: $showClearLogsConfirm) { Button("Clear", role: .destructive) { Task { await clearLogs() } } @@ -176,9 +177,10 @@ private struct FirmwareSection: View { } footer: { footer } - .confirmationDialog("Update firmware?", - isPresented: $showUpdateConfirm, - titleVisibility: .visible) { + // Alerts (centered) rather than anchored confirmation dialogs — see + // the note on the settings-screen confirms above. + .alert("Update firmware?", + isPresented: $showUpdateConfirm) { Button("Update") { Task { await viewModel.startUpdate() } } @@ -186,9 +188,8 @@ private struct FirmwareSection: View { } message: { Text("Keep MetaWear open with the board nearby and powered until the update finishes. The board restarts automatically when it's done.") } - .confirmationDialog("Reinstall current firmware?", - isPresented: $showReinstallConfirm, - titleVisibility: .visible) { + .alert("Reinstall current firmware?", + isPresented: $showReinstallConfirm) { Button("Reinstall", role: .destructive) { Task { await viewModel.startUpdate(forceReinstall: true) } } From 81bcd0bfdef6d5d347adeabf1f6d36bde5d21c47 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 21:27:02 -0700 Subject: [PATCH 11/13] Request real download progress: readout notify delta was 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: the log-download bar jumped 0 to 100 with nothing in between. The readout command's second uint32 is "notify every N entries transferred" (spec, Logging 0x06) — we passed 0, which disables intermediate 0x08 progress notifications entirely, so real firmware sent only the final remaining==0 notice. (Demo Mode masked it: the demo transport fabricates periodic progress regardless of the delta.) runDownload was already built to yield a snapshot per notification. Delta is now nEntries/100 (floor 1): ~100 progress updates per download regardless of size. Co-Authored-By: Claude Opus 4.8 --- Sources/MetaWear/MetaWearDevice.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index e7e6128..bcdee15 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -849,10 +849,16 @@ public actor MetaWearDevice { return stream } - // Readout: [0x0B, 0x06, n_entries(4 LE), n_notify(4 LE)] - // n_notify = 0 means one progress update per page. + // Readout: [0x0B, 0x06, n_entries(4 LE), notify_delta(4 LE)] + // The delta is "send a 0x08 progress notification every N entries + // transferred" (spec, Logging 0x06). A delta of 0 disables + // intermediate progress ENTIRELY — the firmware then sends only + // the final remaining==0 notice, which made the download bar jump + // 0 → 100 with nothing in between. Aim for ~100 updates across + // the download, floor 1 so tiny logs still progress. + let notifyDelta = max(1, nEntries / 100) let cmd = MWPacket.command(.logging, 0x06, - MWPacketParser.le32(nEntries) + MWPacketParser.le32(0)) + MWPacketParser.le32(nEntries) + MWPacketParser.le32(notifyDelta)) try await proto.write(cmd) let (stream, continuation) = AsyncThrowingStream, Error>.makeStream() From 81cbe07ed541ee836e281300852da05714366b58 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 21:35:14 -0700 Subject: [PATCH 12/13] Make rename visibly do something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: renaming a board appeared to do nothing. Four stacked causes: 1. Every displayed name reads the scanner's advertised-name cache, which only updates from advertisements — and a CONNECTED board doesn't advertise, so nothing on screen could change until a full disconnect + rescan + reconnect cycle. The rename now injects the expected name into the cache (new scanner API noteAdvertisedName, documented for exactly this known-stale case; the next real advertisement reconciles). 2. The synced RememberedDevice record only picked the name up on a future reconnect. AppStore.renameRememberedDevice updates it immediately — visible in the scan list now, and CloudKit carries it to the user's other devices without waiting. 3. Rename/factory-reset errors were recorded in lastError but nothing in Settings presented them, so an invalid name (>26 chars, odd characters) failed in complete silence. Settings now alerts on lastError, disables Save while the draft is invalid, and states the rules in the section footer. 4. The MAC-broadcast name refresh was gated on the HOST-LOCAL configured set, so renaming a board configured by the user's other device skipped the refresh and the old name kept broadcasting forever. The gate now also accepts an observed MAC advertisement. Co-Authored-By: Claude Opus 4.8 --- Apps/MetaWear/MetaWear/App/AppStore.swift | 15 ++++++++++++ .../Settings/DeviceSettingsView.swift | 18 ++++++++++++-- .../MetaWear/ViewModels/DeviceViewModel.swift | 24 +++++++++++++++---- Sources/MetaWear/MetaWearScanner.swift | 11 +++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index 7472457..9c2c519 100644 --- a/Apps/MetaWear/MetaWear/App/AppStore.swift +++ b/Apps/MetaWear/MetaWear/App/AppStore.swift @@ -431,6 +431,21 @@ final class AppStore { connectedRSSI = nil } + /// Rename the synced record for a board immediately. Without this, + /// the record's name only refreshes on a future reconnect after fresh + /// advertisements — the rename would look like it did nothing, and the + /// user's other devices wouldn't see the new name until then either. + func renameRememberedDevice(peripheralUUID: UUID, mac: String?, to name: String) { + let record = rememberedDevices.first { + ($0.macAddress != nil && $0.macAddress == mac) + || $0.peripheralUUID == peripheralUUID + } + guard let record, record.name != name else { return } + record.name = name + try? containers.cloud.mainContext.save() + refreshRememberedDevices() + } + func forget(_ remembered: RememberedDevice) { let context = containers.cloud.mainContext context.delete(remembered) diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index 927dee7..d4b6797 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -21,13 +21,17 @@ struct DeviceSettingsView: View { var body: some View { Form { - Section("Rename") { + Section { TextField("Device name", text: $draftName) .textInputAutocapitalization(.never) Button("Save", systemImage: "checkmark") { Task { await viewModel?.rename(to: draftName) } } - .disabled(draftName.isEmpty) + .disabled(!MWSettings.isNameValid(draftName)) + } header: { + Text("Rename") + } footer: { + Text("Up to \(MWSettings.maxDeviceNameLength) characters: letters, numbers, spaces, _ and -. The name updates in the app immediately and on the board's next advertisement.") } if let firmware { @@ -113,6 +117,16 @@ struct DeviceSettingsView: View { message: Text(err.message), dismissButton: .default(Text("OK"))) } + // Rename / factory-reset errors were previously swallowed — the + // view model recorded them but nothing here presented them. + .alert(item: Binding( + get: { viewModel?.lastError }, + set: { viewModel?.lastError = $0 } + )) { err in + Alert(title: Text("Operation failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } } /// Read `LOG_LENGTH` and enumerate active loggers so the section diff --git a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift index 98441cf..7ab6502 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift @@ -72,6 +72,14 @@ final class DeviceViewModel { func rename(to newName: String) async { do { try await device.send(MWSettings.SetDeviceName(validating: newName)) + // Make the rename VISIBLE immediately. Every displayed name comes + // from the scanner's advertised-name cache, and a connected board + // doesn't advertise — without these, nothing on screen changes + // until a disconnect + rescan + reconnect cycle. + appStore.scanner.noteAdvertisedName(newName, for: device.identifier) + appStore.renameRememberedDevice( + peripheralUUID: device.identifier, mac: macAddress, to: newName + ) await refreshMACBroadcastAfterRename(newName) } catch { lastError = AppError(error: error) @@ -79,12 +87,18 @@ final class DeviceViewModel { } /// The MAC-broadcast scan response freezes its embedded name at - /// configuration time — keep it in sync after a rename on boards this - /// app configured. Best-effort: a failure leaves the OLD name on air - /// until the next reconfiguration; it never blocks the rename itself. + /// configuration time — keep it in sync after a rename on any board + /// that broadcasts its MAC. Gated on the host-local configured set OR + /// the observed advertisement: a board configured by the user's OTHER + /// device must be refreshed here too, or its scan response would keep + /// broadcasting the old name forever. Best-effort: a failure leaves the + /// old name on air until the next reconfiguration; it never blocks the + /// rename itself. private func refreshMACBroadcastAfterRename(_ newName: String) async { - guard let mac = macAddress, - appStore.macAdvertisementConfigured.contains(mac) else { return } + guard let mac = macAddress else { return } + let broadcastsMAC = appStore.macAdvertisementConfigured.contains(mac) + || appStore.scanner.advertisedMACs[device.identifier] != nil + guard broadcastsMAC else { return } do { try await device.updateMACAdvertisement(advertisedName: newName) AppStore.log.info("Refreshed MAC broadcast name for \(mac, privacy: .public)") diff --git a/Sources/MetaWear/MetaWearScanner.swift b/Sources/MetaWear/MetaWearScanner.swift index e804899..e72d507 100644 --- a/Sources/MetaWear/MetaWearScanner.swift +++ b/Sources/MetaWear/MetaWearScanner.swift @@ -170,6 +170,17 @@ public final class MetaWearScanner { } } + /// Override the cached advertised name for `uuid`. + /// + /// For rename flows: after `MWSettings.SetDeviceName` the cache is + /// KNOWN-stale — a connected board doesn't advertise, so no observation + /// will correct it until after disconnect. The app injects the expected + /// name so UI keyed off this cache updates immediately; the next real + /// advertisement reconciles it with the truth. + public func noteAdvertisedName(_ name: String, for uuid: UUID) { + advertisedNames[uuid] = name + } + /// Forget the cached advertised name for a UUID so the next scan must /// observe a fresh advertisement before `advertisedNames[uuid]` is /// repopulated. Used by verification code that needs to prove a rename From c29ee2ca6a54327be1d290651d79e29ad47b4d22 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sat, 18 Jul 2026 21:39:48 -0700 Subject: [PATCH 13/13] Pop back to the device page after a successful rename The new name in the device header is the confirmation. rename(to:) now reports success so Settings only dismisses when the board accepted the name; failures stay put with the error alert. Co-Authored-By: Claude Opus 4.8 --- .../Features/Settings/DeviceSettingsView.swift | 10 +++++++++- .../MetaWear/MetaWear/ViewModels/DeviceViewModel.swift | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index d4b6797..4152124 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -5,6 +5,7 @@ import MetaWearFirmware struct DeviceSettingsView: View { @Environment(AppStore.self) private var appStore + @Environment(\.dismiss) private var dismiss @State private var viewModel: DeviceViewModel? @State private var firmware: FirmwareUpdateViewModel? @State private var draftName: String = "" @@ -25,7 +26,14 @@ struct DeviceSettingsView: View { TextField("Device name", text: $draftName) .textInputAutocapitalization(.never) Button("Save", systemImage: "checkmark") { - Task { await viewModel?.rename(to: draftName) } + Task { + // Pop back to the device page on success — the new + // name in its header IS the confirmation. On failure + // stay put; the lastError alert explains. + if await viewModel?.rename(to: draftName) == true { + dismiss() + } + } } .disabled(!MWSettings.isNameValid(draftName)) } header: { diff --git a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift index 7ab6502..27ab211 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift @@ -69,7 +69,11 @@ final class DeviceViewModel { } } - func rename(to newName: String) async { + /// - Returns: `true` on success, so callers can navigate to where the + /// new name is visible; `false` leaves the user in place with the + /// error surfaced via `lastError`. + @discardableResult + func rename(to newName: String) async -> Bool { do { try await device.send(MWSettings.SetDeviceName(validating: newName)) // Make the rename VISIBLE immediately. Every displayed name comes @@ -81,8 +85,10 @@ final class DeviceViewModel { peripheralUUID: device.identifier, mac: macAddress, to: newName ) await refreshMACBroadcastAfterRename(newName) + return true } catch { lastError = AppError(error: error) + return false } }