From f60b5c48a8b508ed31f83838d9a9129b82d8994b Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 16:37:42 -0700 Subject: [PATCH 01/17] Group logging: log on a whole fleet at once, MetaBase style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10.1 headline, scoped to what users actually need: "log data on multiple sensors at once." Logging is connectionless — boards record to their own flash — so the app never holds N live links; it ORCHESTRATES: sequential connect → act → disconnect per board. - GroupCaptureCoordinator (owned by AppStore, never a view): start pass connects each board, starts the shared config's loggers, stamps group-tagged LogSessionRecords, disconnects. Collect pass is the catch-all: stop own running records → download; stopped records → download; NO local records but data/logging on the board (other phone, reinstall) → anonymous foreign download; out of range → skipped, data stays on flash for later. 15 s connect timeout per board so an absent board costs one wait, not the walk. - Drives SDK device.connect() directly — AppStore.connect is entangled with navigation, the active-device slot, and the orphan alert, none of which should fire N times mid-pass. Boards in use elsewhere in the app (.streaming, solo flows) are skipped, and an .idle active device is borrowed without being disconnected. - GroupLoggingView (pushed from the scan screen; badge turns red while a fleet records): candidate picker (remembered + fresh nearby + demo fleet, off-air boards labelled), ONE shared sensor config, Start Logging All, per-board phase rows with live download %, active-group section that survives force-quits (derived from group-tagged pending records), Stop & Download All behind a confirmation. Boards missed by the first pass can be added later and join the SAME batch. Adversarial review: 13 findings confirmed, all fixed. The two critical: a cleanup disconnect after a "peripheral not found" connect parked a continuation nothing resumes (wedging the walk with isBusy stuck — now guarded by device state, with a regression test), and the collect pass resolving demo boards through the scanner, minting CoreBluetooth twins that can never connect in the simulator (now routed demo-fleet-first). Also: honest per-board states (no green check on kept-board-data warnings; stopped boards show "Ready To Collect", not a pulsing record dot), live selection (off-air boards can't be silently dropped while the footer counts them), the shared scan surviving the push, and solo Stop no longer resurrecting records a group collect already downloaded. Validation: 5 end-to-end coordinator tests against the demo fleet (real SDK connects, logger round-trips, persistence — only the radio is fake), 1058 SPM + full app suite green. Hardware pass on the desk MMS + MMR is the remaining gate. Co-Authored-By: Claude Fable 5 --- Apps/MetaWear/MetaWear/App/AppStore.swift | 13 + .../Features/Logging/GroupLoggingView.swift | 369 ++++++++++++++++++ .../MetaWear/Features/Scan/ScanView.swift | 28 +- .../ViewModels/GroupCaptureCoordinator.swift | 304 +++++++++++++++ .../ViewModels/LogSessionViewModel.swift | 26 +- .../GroupCaptureCoordinatorTests.swift | 147 +++++++ 6 files changed, 879 insertions(+), 8 deletions(-) create mode 100644 Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift create mode 100644 Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift create mode 100644 Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index e1645e3..7c37ce0 100644 --- a/Apps/MetaWear/MetaWear/App/AppStore.swift +++ b/Apps/MetaWear/MetaWear/App/AppStore.swift @@ -65,6 +65,19 @@ final class AppStore { // MARK: - Demo device + /// Orchestrates group logging (start/stop-and-download across several + /// boards). Owned here — never by a view — so an in-flight fleet walk + /// survives navigation. Created lazily; solo sessions never pay for it. + private var _groupCapture: GroupCaptureCoordinator? + var groupCapture: GroupCaptureCoordinator { + if let coordinator = _groupCapture { return coordinator } + let coordinator = GroupCaptureCoordinator( + containers: containers, persistence: persistence, appStore: self + ) + _groupCapture = coordinator + return coordinator + } + /// The fully simulated MetaWear fleet (see `DemoBLETransport.Identity`). /// Created on first access so non-demo sessions never pay for it; each /// board is a stable instance reused across connect/disconnect cycles diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift new file mode 100644 index 0000000..8005203 --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -0,0 +1,369 @@ +import SwiftUI +import MetaWear + +/// MetaBase-style group logging: pick several boards, pick ONE shared +/// sensor config, start them all logging with a single tap — then come +/// back any time (logging is connectionless) and stop-and-download the +/// whole fleet. Orchestration lives in `AppStore.groupCapture`, which +/// walks the boards sequentially; this view only selects members and +/// renders per-board progress. +struct GroupLoggingView: View { + @Environment(AppStore.self) private var appStore + @State private var scanVM: ScannerViewModel? + @State private var selectedIDs: Set = [] + @State private var selections: [SensorSelection] = [ + SensorSelection(id: .accelerometer, hz: SensorKey.accelerometer.defaultHz, range: 2) + ] + @State private var showStopConfirm = false + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { timeline in + Form { + let coordinator = appStore.groupCapture + let active = activeGroupRecords + + // Hide "Last Run" when it would only duplicate the + // active-group section (a fully successful start pass: + // every board it lists is already shown, recording, under + // "Logging In Progress"). + let lastRunIsRedundant = !coordinator.isBusy + && !active.isEmpty + && coordinator.boards.allSatisfy { $0.phase == .logging } + if (coordinator.isBusy || !coordinator.boards.isEmpty) && !lastRunIsRedundant { + progressSection(coordinator: coordinator) + } + + if !active.isEmpty { + activeGroupSection(records: active) + } + // The picker stays reachable while a group is live so a + // board that was out of range during the first pass can + // still be added — its sessions join the SAME batch. + if !coordinator.isBusy { + boardPickerSection(now: timeline.date) + SensorPickerSection( + selections: $selections, + availableModules: Set(MWModule.allCases), + availableTempChannels: [], + supportedKinds: Self.groupLoggableKinds, + isLocked: false + ) + startSection(now: timeline.date, joining: active.first?.groupID) + } + } + } + .navigationTitle("Group Logging") + .task { + if scanVM == nil { scanVM = ScannerViewModel(scanner: appStore.scanner) } + scanVM?.startScan() + appStore.refreshPendingLogSessions() + } + .confirmationDialog( + "Stop logging on all boards and download their data?", + isPresented: $showStopConfirm, + titleVisibility: .visible + ) { + Button("Stop & Download All") { + let members = activeGroupMembers + Task { await appStore.groupCapture.stopAndDownloadAll(members: members) } + } + } message: { + Text("Each board is collected in turn. Boards that are out of range are skipped — their data stays on the board for later.") + } + } + + // MARK: - Sections + + /// Per-board walk progress for the pass that is running (or just ran). + @ViewBuilder + private func progressSection(coordinator: GroupCaptureCoordinator) -> some View { + Section { + ForEach(coordinator.boards) { board in + HStack(spacing: 12) { + phaseIcon(board.phase) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(board.name) + .font(.body.weight(.medium)) + Text(phaseText(board.phase)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if case .downloading = board.phase, + let download = coordinator.activeDownload, + case .downloading(let progress, _, _) = download.phase { + Text(progress, format: .percent.precision(.fractionLength(0))) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + } header: { + Text(coordinator.isBusy ? "Working…" : "Last Run") + } footer: { + if !coordinator.isBusy { + Text("Skipped boards keep their data — collect them here later, or connect to one directly and use its Logging screen.") + } + } + } + + /// The fleet is recording — show who, since when, and the collect button. + @ViewBuilder + private func activeGroupSection(records: [LogSessionRecord]) -> some View { + let anyRunning = records.contains { $0.status == .running } + return Section { + ForEach(groupedByBoard(records), id: \.0) { deviceID, boardRecords in + let isRunning = boardRecords.contains { $0.status == .running } + HStack { + Image(systemName: isRunning ? "record.circle.fill" : "pause.circle.fill") + .foregroundStyle(isRunning ? Palette.danger : Palette.warning) + .symbolEffect(.pulse, options: .repeating, isActive: isRunning) + VStack(alignment: .leading, spacing: 2) { + Text(displayName(for: deviceID)) + .font(.body.weight(.medium)) + Text(isRunning + ? "\(boardRecords.count) sensor\(boardRecords.count == 1 ? "" : "s") · since \(boardRecords.map(\.startDate).min() ?? .now, format: .dateTime.hour().minute())" + : "Stopped — awaiting download") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + Button { + showStopConfirm = true + } label: { + Label(anyRunning ? "Stop & Download All" : "Download All", + systemImage: "square.and.arrow.down.on.square") + } + .disabled(appStore.groupCapture.isBusy) + } header: { + Text(anyRunning ? "Logging In Progress" : "Ready To Collect") + } footer: { + Text("The boards record on their own — you can close the app or leave. Come back here to collect everything at once.") + } + } + + @ViewBuilder + private func boardPickerSection(now: Date) -> some View { + Section { + let candidates = candidates(now: now) + if candidates.isEmpty { + Text("No boards available — bring them in range, or remember them by connecting once.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(candidates, id: \.device.identifier) { candidate in + Button { + toggle(candidate.device.identifier) + } label: { + HStack { + Image(systemName: selectedIDs.contains(candidate.device.identifier) + ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedIDs.contains(candidate.device.identifier) + ? Palette.accent : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(candidate.name) + .foregroundStyle(.primary) + if appStore.hasPendingLog(forPeripheral: candidate.device.identifier) { + Text("Has a session waiting — download it first") + .font(.caption) + .foregroundStyle(Palette.warning) + } + let isOffAir = DemoMode.name(for: candidate.device.identifier) == nil + && !DeviceFreshness.isFresh( + lastSeen: appStore.scanner.advertisementLastSeen[candidate.device.identifier], + now: now + ) + if isOffAir { + Text("Not seen nearby — starting may wait up to 15 s") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + } header: { + Text("Boards") + } footer: { + Text("Every selected board records the same sensors. Boards are set up one at a time and keep logging on their own — no connection needed while they record.") + } + } + + /// Members, footer count, and enablement all derive from ONE live + /// computation — a board that went off-air after being checked must + /// not be silently dropped while the footer still counts it. + private func startSection(now: Date, joining groupID: UUID?) -> some View { + let members = selectedMembers(now: now) + return Section { + Button { + Task { + await appStore.groupCapture.startAll( + members: members, selections: selections, joining: groupID + ) + } + } label: { + Label(groupID == nil ? "Start Logging All" : "Add To Group", + systemImage: "record.circle.fill") + .font(.body.weight(.semibold)) + } + .disabled(members.isEmpty || selections.isEmpty || appStore.groupCapture.isBusy) + } footer: { + Text(members.isEmpty + ? "Select at least one board above." + : "\(members.count) board\(members.count == 1 ? "" : "s") will start logging\(groupID == nil ? "" : " and join the group").") + } + } + + // MARK: - Candidates & members + + private struct Candidate { + let device: MetaWearDevice + let name: String + } + + /// Boards eligible for a group: remembered boards (connectable by known + /// identifier even when off-air), freshly advertising nearby boards, + /// and the demo fleet. Deduped by peripheral UUID. + private func candidates(now: Date) -> [Candidate] { + var seen = Set() + var result: [Candidate] = [] + + for remembered in appStore.rememberedDevices { + let localID = appStore.localPeripheralUUID(for: remembered) + guard seen.insert(localID).inserted else { continue } + let device = appStore.scanner.device(forKnownIdentifier: localID) + result.append(Candidate(device: device, name: remembered.name ?? "MetaWear")) + } + for device in scanVM?.devices ?? [] { + guard DeviceFreshness.isFresh( + lastSeen: appStore.scanner.advertisementLastSeen[device.identifier], now: now + ) else { continue } + guard seen.insert(device.identifier).inserted else { continue } + let name = scanVM?.advertisedName(for: device.identifier) ?? "MetaWear" + result.append(Candidate(device: device, name: name)) + } + if DemoMode.isEnabled { + for demo in appStore.demoDevices { + guard seen.insert(demo.identifier).inserted else { continue } + result.append(Candidate( + device: demo, + name: DemoMode.name(for: demo.identifier) ?? DemoMode.deviceName + )) + } + } + return result + } + + private func selectedMembers(now: Date) -> [GroupCaptureCoordinator.Member] { + candidates(now: now) + .filter { selectedIDs.contains($0.device.identifier) } + .map { GroupCaptureCoordinator.Member(device: $0.device, name: $0.name) } + } + + // MARK: - Active group + + /// Pending group-tagged records — the durable "a fleet is recording" + /// signal. Survives app restarts (they're SwiftData rows), so this + /// screen recovers the fleet even after a force-quit. + private var activeGroupRecords: [LogSessionRecord] { + appStore.pendingLogSessions.filter { $0.groupID != nil } + } + + /// Members for the collect pass, resolved from the active records — + /// names come from live sources (remembered/advertised/demo) since + /// pending records don't carry one. + private var activeGroupMembers: [GroupCaptureCoordinator.Member] { + groupedByBoard(activeGroupRecords).map { deviceID, _ in + GroupCaptureCoordinator.Member( + device: resolveDevice(for: deviceID), + name: displayName(for: deviceID) + ) + } + } + + /// Demo boards live ONLY in `AppStore.demoDevices` — the scanner knows + /// nothing about them, and `device(forKnownIdentifier:)` would mint a + /// CoreBluetooth-backed twin that can never connect in the simulator. + /// Route demo IDs to the fleet first, everything else to the scanner. + private func resolveDevice(for deviceID: UUID) -> MetaWearDevice { + if let demo = appStore.demoDevices.first(where: { $0.identifier == deviceID }) { + return demo + } + return appStore.scanner.device(forKnownIdentifier: deviceID) + } + + private func groupedByBoard(_ records: [LogSessionRecord]) -> [(UUID, [LogSessionRecord])] { + Dictionary(grouping: records, by: \.deviceID) + .sorted { ($0.value.first?.startDate ?? .distantPast) < ($1.value.first?.startDate ?? .distantPast) } + .map { ($0.key, $0.value) } + } + + private func displayName(for deviceID: UUID) -> String { + if let demoName = DemoMode.name(for: deviceID) { return demoName } + if let advertised = appStore.scanner.advertisedNames[deviceID], !advertised.isEmpty { + return advertised + } + if let remembered = appStore.rememberedDevices.first(where: { + $0.peripheralUUID == deviceID || appStore.localPeripheralUUID(for: $0) == deviceID + })?.name, !remembered.isEmpty { + return remembered + } + return "MetaWear" + } + + private func toggle(_ id: UUID) { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + } else { + selectedIDs.insert(id) + } + } + + // MARK: - Phase rendering + + @ViewBuilder + private func phaseIcon(_ phase: GroupCaptureCoordinator.BoardPhase) -> some View { + switch phase { + case .pending: + Image(systemName: "circle.dotted").foregroundStyle(.secondary) + case .connecting, .starting, .stopping, .downloading: + ProgressView().controlSize(.small) + case .logging: + Image(systemName: "record.circle.fill").foregroundStyle(Palette.danger) + case .saved: + Image(systemName: "checkmark.circle.fill").foregroundStyle(Palette.success) + case .skipped: + Image(systemName: "minus.circle").foregroundStyle(Palette.warning) + case .failed: + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.danger) + } + } + + private func phaseText(_ phase: GroupCaptureCoordinator.BoardPhase) -> String { + switch phase { + case .pending: return "Waiting…" + case .connecting: return "Connecting…" + case .starting: return "Starting loggers…" + case .logging: return "Logging" + case .stopping: return "Stopping…" + case .downloading: return "Downloading…" + case .saved(let count): return "Saved \(count) session\(count == 1 ? "" : "s")" + case .skipped(let reason): return reason + case .failed(let message): return message + } + } + + /// Group logging offers the natively-loggable sensor families. Polled + /// sensors (temperature / humidity) are excluded for now: their + /// board-side timer handles would need per-board bookkeeping in the + /// group flow, and the headline use case is IMU fleets. + private static let groupLoggableKinds: Set = { + var kinds = Set(SensorKey.Kind.allCases) + kinds.remove(.temperature) + kinds.remove(.humidity) + return kinds + }() +} diff --git a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift index 88f0dee..8645bb5 100644 --- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift +++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift @@ -11,6 +11,7 @@ struct ScanView: View { /// detail column never re-appears in compact width. let showDetail: () -> Void @State private var viewModel: ScannerViewModel? + @State private var showGroupLogging = false private var pinnedID: UUID? { appStore.rememberedDevices.first?.peripheralUUID @@ -174,6 +175,20 @@ struct ScanView: View { ) } } + 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. + Button { + showGroupLogging = true + } label: { + Label("Group Logging", systemImage: "square.stack.3d.down.right") + } + .tint(hasActiveGroup ? Palette.danger : nil) + } + } + .navigationDestination(isPresented: $showGroupLogging) { + GroupLoggingView() } .task { if viewModel == nil { @@ -181,7 +196,18 @@ struct ScanView: View { } viewModel?.startScan() } - .onDisappear { viewModel?.stopScan() } + .onDisappear { + // Group Logging (pushed from here) needs the shared scan alive + // for its nearby candidates — both screens drive the SAME + // MetaWearScanner, so stopping on push would freeze freshness. + if !showGroupLogging { viewModel?.stopScan() } + } + } + + /// True while any pending session carries a group tag — a fleet is + /// recording (or awaiting collection). + private var hasActiveGroup: Bool { + appStore.pendingLogSessions.contains { $0.groupID != nil } } private func status(for uuid: UUID, now: Date) -> DeviceConnectionStatus { diff --git a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift new file mode 100644 index 0000000..80e60f8 --- /dev/null +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -0,0 +1,304 @@ +import Foundation +import Observation +import MetaWear +import MetaWearPersistence + +/// Orchestrates MetaBase-style group logging: start logging on several +/// boards with one shared config, then stop-and-download them all later. +/// +/// Logging is connectionless — boards record to their own flash with no +/// phone anywhere near them — so the coordinator never holds N live links. +/// It walks the fleet SEQUENTIALLY (one board at a time; the radio is +/// shared and one-at-a-time is the reliable shape): connect → act → +/// disconnect, then the next board. Boards are driven through the SDK +/// (`device.connect()`) directly, NOT `AppStore.connect(to:)` — the +/// AppStore path is entangled with navigation, the active-device slot, and +/// the connect-time orphan alert, none of which should fire N times during +/// a group pass. +/// +/// Owned by `AppStore` (not a view) so an in-flight pass survives +/// navigation — the lesson of the foreign-download saga: never let a +/// destructive BLE operation ride a view's `.task` lifetime. +@Observable +@MainActor +final class GroupCaptureCoordinator { + + // MARK: - Per-board progress + + enum BoardPhase: Equatable { + case pending + case connecting + case starting + /// Start pass succeeded — the board is recording on its own. + case logging + case stopping + case downloading + /// Collect pass succeeded; the payload is the saved session count. + case saved(Int) + /// The board was left untouched, with the reason (out of range, + /// nothing to download, …). Not an error: absent boards can be + /// collected individually later via the normal per-board flow. + case skipped(String) + case failed(String) + + var isTerminal: Bool { + switch self { + case .logging, .saved, .skipped, .failed: return true + default: return false + } + } + } + + struct BoardProgress: Identifiable, Equatable { + let id: UUID + let name: String + var phase: BoardPhase = .pending + } + + /// One board the caller wants in the group: a resolved device handle + /// plus the display name to stamp onto its sessions. + struct Member { + let device: MetaWearDevice + let name: String + } + + // MARK: - Observable state + + private(set) var boards: [BoardProgress] = [] + /// True while a start or collect pass is walking the fleet. + private(set) var isBusy = false + /// The download engine for whichever board is currently draining — + /// exposed so the UI can render its live progress bar. Nil between + /// boards and outside collect passes. + private(set) var activeDownload: DownloadViewModel? + + private let containers: AppContainers + private let persistence: MWPersistenceStore + private unowned let appStore: AppStore + + /// How long a sequential pass waits for one board's connect before + /// declaring it absent and moving on. CoreBluetooth itself never times + /// out a connect — without this cap one missing board wedges the whole + /// fleet walk forever. + static let connectTimeout: Duration = .seconds(15) + + init(containers: AppContainers, persistence: MWPersistenceStore, appStore: AppStore) { + self.containers = containers + self.persistence = persistence + self.appStore = appStore + } + + // MARK: - Start pass + + /// Sequentially start logging `selections` (one shared config) on every + /// member. Each board: connect → start loggers → stamp group-tagged + /// `LogSessionRecord`s → disconnect. Boards that already carry a + /// pending session are skipped — starting over it would fight the + /// existing session for logger slots. + /// - Parameter existingGroupID: pass the live group's ID to ADD boards + /// to it (e.g. a member that was out of range during the first pass) + /// instead of minting a new batch. + func startAll( + members: [Member], + selections: [SensorSelection], + joining existingGroupID: UUID? = nil + ) async { + guard !isBusy, !members.isEmpty, !selections.isEmpty else { return } + isBusy = true + defer { isBusy = false } + + let groupID = existingGroupID ?? UUID() + boards = members.map { BoardProgress(id: $0.device.identifier, name: $0.name) } + + for member in members { + let id = member.device.identifier + if appStore.hasPendingLog(forPeripheral: id) { + setPhase(id, .skipped("Already has a session — download it first")) + continue + } + do { + setPhase(id, .connecting) + let ownsConnection = try await connectIfNeeded(member.device) + setPhase(id, .starting) + let vm = LogSessionViewModel(device: member.device, containers: containers) + await vm.start(selections, groupID: groupID) + if case .running = vm.phase { + setPhase(id, .logging) + } else { + setPhase(id, .failed(vm.lastError?.message ?? "Logging did not start")) + } + if ownsConnection { try? await member.device.disconnect() } + } catch { + setPhase(id, .failed(Self.friendlyConnectError(error))) + } + } + appStore.refreshPendingLogSessions() + } + + // MARK: - Collect pass + + /// Sequentially stop and download every member — the catch-all pass: + /// • our own running records → stop, download, save (group-tagged) + /// • stopped-but-not-downloaded records → download, save + /// • no local records but data/logging on the board (started on + /// another phone, reinstall) → anonymous foreign download + /// • board out of range → skipped; its data stays on flash and the + /// normal per-board flow can collect it any time later + func stopAndDownloadAll(members: [Member]) async { + guard !isBusy, !members.isEmpty else { return } + isBusy = true + defer { + isBusy = false + activeDownload = nil + } + + boards = members.map { BoardProgress(id: $0.device.identifier, name: $0.name) } + + for member in members { + let id = member.device.identifier + do { + setPhase(id, .connecting) + let ownsConnection = try await connectIfNeeded(member.device) + await collectOne(member: member) + if ownsConnection { try? await member.device.disconnect() } + } catch { + setPhase(id, .skipped(Self.friendlyConnectError(error))) + } + appStore.refreshPendingLogSessions() + } + } + + /// Stop + drain one connected board. Never throws: every outcome lands + /// in the board's phase so one board's failure can't abort the walk. + private func collectOne(member: Member) async { + let id = member.device.identifier + let pending = appStore.pendingLogSessions.filter { $0.deviceID == id } + let running = pending.filter { $0.status == .running } + + if !running.isEmpty { + setPhase(id, .stopping) + let vm = LogSessionViewModel(device: member.device, containers: containers) + vm.restoreFromPending(records: running) + await vm.stop() + } + + setPhase(id, .downloading) + let download = DownloadViewModel( + device: member.device, + store: persistence, + containers: containers, + deviceName: member.name + ) + activeDownload = download + + if !pending.isEmpty { + await download.downloadAll(records: pending) + } else { + // Catch-all: nothing local claims this board. If its flash + // holds entries (or it is actively logging), recover via the + // anonymous-logger path — same engine as the Logging screen's + // foreign-session card. + let entryCount = (try? await member.device.read(MWLogLength()).value) ?? 0 + let loggingEnabled = (try? await member.device.read(MWLoggingEnabled()).value) ?? false + guard entryCount > 0 || loggingEnabled else { + activeDownload = nil + setPhase(id, .skipped("Nothing to download")) + return + } + let state = OrphanLogState( + entryCount: entryCount, deviceID: id, isActivelyLogging: loggingEnabled + ) + await download.downloadForeign(state) + if case .ready = download.phase { + appStore.clearForeignLog(for: id) + } + } + + switch download.phase { + case .ready(let snapshots, let warning): + if let warning { + // Partial success (some records undecodable, board data + // kept, …) must not render a green check — the warning is + // the actionable truth and the board needs a retry. + setPhase(id, .failed(warning)) + } else { + setPhase(id, .saved(snapshots.count)) + } + case .failed(let message): + setPhase(id, .failed(message)) + default: + setPhase(id, .failed("Download did not complete")) + } + activeDownload = nil + } + + // MARK: - Helpers + + /// Connect unless the board is already usable. Policy by state: + /// • `.idle` — the app already holds the link (it's the active + /// device, parked): BORROW it; the caller must not disconnect. + /// • `.disconnected` — open our own connection (caller owns it). + /// • anything else (`.connecting`/`.streaming`/`.logging`/ + /// `.downloading`) — some other flow is actively driving this + /// board (Live Stream, a solo download, a user-initiated + /// connect); barging in would corrupt its state machine. Skip. + /// Bounded by `connectTimeout`: an absent board must cost one + /// timeout, not wedge the fleet walk. + /// - Returns: true when this call opened the connection (caller owns it). + private func connectIfNeeded(_ device: MetaWearDevice) async throws -> Bool { + switch await device.state { + case .idle: + return false + case .disconnected: + break + default: + throw BoardBusyError() + } + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await device.connect() + } + group.addTask { + try await Task.sleep(for: Self.connectTimeout) + throw ConnectTimeoutError() + } + do { + try await group.next() + group.cancelAll() + } catch { + group.cancelAll() + // Cancel the pending CoreBluetooth connect so the board + // doesn't attach mid-walk to a slot nobody owns anymore. + // ONLY when a connect is actually pending: after an + // outright failure ("Peripheral not found") the device is + // already back at .disconnected, the central never + // registered the peripheral, and a transport disconnect + // would park a continuation nothing will ever resume — + // wedging the walk with isBusy stuck true. + if await device.state != .disconnected { + try? await device.disconnect() + } + throw error + } + } + return true + } + + private struct ConnectTimeoutError: Error {} + private struct BoardBusyError: Error {} + + private static func friendlyConnectError(_ error: Error) -> String { + if error is ConnectTimeoutError { + return "Not found nearby — bring the board closer and retry, or download from it individually later" + } + if error is BoardBusyError { + return "In use elsewhere in the app — leave its screen and retry" + } + return (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } + + private func setPhase(_ id: UUID, _ phase: BoardPhase) { + guard let index = boards.firstIndex(where: { $0.id == id }) else { return } + boards[index].phase = phase + } +} diff --git a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift index db6bd70..6ea2bd6 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift @@ -33,7 +33,10 @@ final class LogSessionViewModel { self.containers = containers } - func start(_ selections: [SensorSelection]) async { + /// - Parameter groupID: stamped onto every created record when this + /// start is part of a group capture (several boards logging together) + /// so the downloaded sessions can be batched back together. + func start(_ selections: [SensorSelection], groupID: UUID? = nil) async { guard case .idle = phase else { return } @@ -43,7 +46,7 @@ final class LogSessionViewModel { var records: [LogSessionRecord] = [] do { for selection in selections { - if let record = try await startOne(selection: selection, chip: chip, context: context) { + if let record = try await startOne(selection: selection, chip: chip, context: context, groupID: groupID) { records.append(record) } } @@ -76,7 +79,8 @@ final class LogSessionViewModel { private func startOne( selection: SensorSelection, chip: MWSensorFusionChip, - context: ModelContext + context: ModelContext, + groupID: UUID? = nil ) async throws -> LogSessionRecord? { switch selection.id { case .temperature: @@ -92,7 +96,8 @@ final class LogSessionViewModel { configJSON: Self.encode(selection), loggerKey: polled.loggerKey, status: .running, - polledHandlesJSON: Self.encodeHandles(handles) + polledHandlesJSON: Self.encodeHandles(handles), + groupID: groupID ) context.insert(record) return record @@ -109,7 +114,8 @@ final class LogSessionViewModel { configJSON: Self.encode(selection), loggerKey: polled.loggerKey, status: .running, - polledHandlesJSON: Self.encodeHandles(handles) + polledHandlesJSON: Self.encodeHandles(handles), + groupID: groupID ) context.insert(record) return record @@ -122,7 +128,8 @@ final class LogSessionViewModel { sensorKind: selection.id.persistenceKey, configJSON: Self.encode(selection), loggerKey: loggable.loggerKey, - status: .running + status: .running, + groupID: groupID ) context.insert(record) return record @@ -144,7 +151,12 @@ final class LogSessionViewModel { // - the captured data is still on the board for download. // If the board kept sampling, the next `cleanUpOrphanResources` // on connect will sweep any leftover state. - for record in activeRecords { + // `where` guard: a group collect pass can stop + download + clear + // this board while a solo Logging screen still holds these records + // in its view model (iPad split view). Re-stopping a `.downloaded` + // record would fire stop commands at logger slots clearLog already + // freed AND resurrect the record as pending everywhere. + for record in activeRecords where record.status != .downloaded { do { try await stopOne(record: record, chip: chip) } catch { diff --git a/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift b/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift new file mode 100644 index 0000000..ee6454e --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift @@ -0,0 +1,147 @@ +import Foundation +import SwiftData +import Testing +import MetaWear +import MetaWearPersistence +@testable import MetaWearApp + +/// End-to-end group capture against the simulated fleet: real SDK +/// connects, real logger round-trips through `DemoBLETransport`, real +/// SwiftData persistence — only the radio is fake. This is the regression +/// net for the whole MetaBase-style flow (start N boards → stop & download +/// N boards) before it ever touches hardware. +@Suite("GroupCaptureCoordinator — demo fleet") +@MainActor +struct GroupCaptureCoordinatorTests { + + private func makeRig() throws -> (AppStore, GroupCaptureCoordinator, [GroupCaptureCoordinator.Member]) { + let containers = try AppModelContainer.makeShared(inMemory: true) + let appStore = AppStore(containers: containers) + let members = appStore.demoDevices.prefix(2).map { + GroupCaptureCoordinator.Member( + device: $0, + name: DemoMode.name(for: $0.identifier) ?? "Demo" + ) + } + return (appStore, appStore.groupCapture, Array(members)) + } + + private let accelSelection = [ + SensorSelection(id: .accelerometer, hz: SensorKey.accelerometer.defaultHz, range: 2) + ] + + @Test func startAllStartsEveryBoardAndDisconnects() async throws { + let (appStore, coordinator, members) = try makeRig() + + await coordinator.startAll(members: members, selections: accelSelection) + + for board in coordinator.boards { + #expect(board.phase == .logging, "\(board.name): \(board.phase)") + } + // One group-tagged record per board, all sharing ONE groupID. + let records = appStore.pendingLogSessions.filter { $0.groupID != nil } + #expect(records.count == members.count) + #expect(Set(records.map(\.groupID)).count == 1) + #expect(Set(records.map(\.deviceID)).count == members.count) + // The walk owns its connections: boards are released afterwards. + for member in members { + #expect(await member.device.state == .disconnected) + } + } + + @Test func stopAndDownloadAllSavesGroupTaggedNamedSessions() async throws { + let (appStore, coordinator, members) = try makeRig() + + await coordinator.startAll(members: members, selections: accelSelection) + // Let the demo boards "record" long enough to bank entries. + try await Task.sleep(for: .seconds(2)) + await coordinator.stopAndDownloadAll(members: members) + + for board in coordinator.boards { + guard case .saved(let count) = board.phase else { + Issue.record("\(board.name) ended \(board.phase), expected .saved") + continue + } + #expect(count >= 1) + } + // No pending records remain; the fleet signal clears. + #expect(appStore.pendingLogSessions.filter { $0.groupID != nil }.isEmpty) + + // Saved sessions carry the full attribution: name, count, ONE + // shared groupID, distinct boards. + let sessions = try await appStore.persistence.fetchAllSessions() + #expect(sessions.count == members.count) + #expect(Set(sessions.compactMap(\.groupID)).count == 1) + #expect(Set(sessions.map(\.deviceID)).count == members.count) + for session in sessions { + #expect(session.deviceName?.hasPrefix("Simulated MetaWear") == true) + #expect(session.sampleCount > 0) + } + } + + @Test func boardWithExistingPendingSessionIsSkippedOnStart() async throws { + let (appStore, coordinator, members) = try makeRig() + let occupied = members[0] + + // Board 0 already carries a solo session. + let record = LogSessionRecord( + deviceID: occupied.device.identifier, + sensorKind: "accelerometer", + configJSON: "{}", + loggerKey: "existing" + ) + appStore.containers.local.mainContext.insert(record) + try appStore.containers.local.mainContext.save() + appStore.refreshPendingLogSessions() + + await coordinator.startAll(members: members, selections: accelSelection) + + #expect(coordinator.boards[0].phase == .skipped("Already has a session — download it first")) + #expect(coordinator.boards[1].phase == .logging) + // The occupied board's record is untouched and NOT group-tagged. + #expect(appStore.pendingLogSessions.first { + $0.deviceID == occupied.device.identifier && $0.loggerKey == "existing" + }?.groupID == nil) + } + + /// A board whose peripheral iOS can't resolve (foreign-host UUID from + /// a CloudKit-synced record, never seen here) fails its connect + /// INSTANTLY — and the cleanup path must not issue a disconnect for a + /// peripheral the central never registered: that parks a continuation + /// nothing resumes, wedging the walk with isBusy stuck true forever. + /// Adversarial review found the hang; this pins the fix. + @Test(.timeLimit(.minutes(1))) + func unresolvableBoardFailsFastWithoutWedgingTheWalk() async throws { + let (appStore, coordinator, members) = try makeRig() + let phantom = GroupCaptureCoordinator.Member( + device: appStore.scanner.device(forKnownIdentifier: UUID()), + name: "Phantom" + ) + + await coordinator.startAll( + members: [phantom, members[0]], selections: accelSelection + ) + + #expect(coordinator.isBusy == false) + if case .failed = coordinator.boards[0].phase {} else { + Issue.record("phantom ended \(coordinator.boards[0].phase), expected .failed") + } + // The walk moved past the phantom: the real board still started. + #expect(coordinator.boards[1].phase == .logging) + } + + @Test func boardWithNothingToDownloadIsSkippedOnCollect() async throws { + // The appStore must stay named and alive: the coordinator's + // back-reference is `unowned` (in the app, AppStore owns the + // coordinator for the process lifetime) — discarding it with `_` + // deallocates it mid-walk and aborts on the unowned load. + let (appStore, coordinator, members) = try makeRig() + + // Collect without ever starting: fresh demo boards hold no entries + // and aren't logging — the catch-all should skip, not fail. + await coordinator.stopAndDownloadAll(members: [members[0]]) + + #expect(coordinator.boards[0].phase == .skipped("Nothing to download")) + #expect(appStore.pendingLogSessions.isEmpty) + } +} From 6146b6fba01b302a11cab20c1f8c724fac9a7d08 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 17:39:58 -0700 Subject: [PATCH 02/17] Board picker rows carry identity: live RSSI + MAC (or iOS identifier) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback: a fleet of stock boards is a screen full of identical "MetaWear" rows — unpickable. Each candidate row now shows: - the MAC when known (remembered record, or the MAC-broadcast advertisement; demo boards render their transport identity's MAC), falling back to the iOS peripheral identifier, middle-truncated, in monospaced caption type - the live RSSI as the standard blue pill while the board is on air (suppressed when the advertisement goes stale — the scanner's RSSI map freezes for off-air boards, and a frozen number lies) Co-Authored-By: Claude Fable 5 --- Apps/MetaWear/MetaWear/App/DemoMode.swift | 11 +++++ .../Features/Logging/GroupLoggingView.swift | 42 +++++++++++++++++-- .../GroupCaptureCoordinatorTests.swift | 11 +++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Apps/MetaWear/MetaWear/App/DemoMode.swift b/Apps/MetaWear/MetaWear/App/DemoMode.swift index 25af347..dbc7a42 100644 --- a/Apps/MetaWear/MetaWear/App/DemoMode.swift +++ b/Apps/MetaWear/MetaWear/App/DemoMode.swift @@ -31,6 +31,17 @@ enum DemoMode { identities.contains { $0.identifier == id } } + /// Display MAC for a demo board (from its transport identity, wire + /// order reversed to the usual big-endian colon form); nil otherwise. + static func macAddress(for id: UUID) -> String? { + guard let identity = identities.first(where: { $0.identifier == id }) else { + return nil + } + return identity.macLSBFirst.reversed() + .map { String(format: "%02X", $0) } + .joined(separator: ":") + } + /// Display name for a demo board ("Simulated MetaWear", "Simulated /// MetaWear 2", …); nil for non-demo IDs. static func name(for id: UUID) -> String? { diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 8005203..664275a 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -165,6 +165,11 @@ struct GroupLoggingView: View { VStack(alignment: .leading, spacing: 2) { Text(candidate.name) .foregroundStyle(.primary) + Text(candidate.detail) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) if appStore.hasPendingLog(forPeripheral: candidate.device.identifier) { Text("Has a session waiting — download it first") .font(.caption) @@ -181,6 +186,10 @@ struct GroupLoggingView: View { .foregroundStyle(.secondary) } } + Spacer() + if let rssi = candidate.rssi { + RSSIPill(dBm: rssi) + } } } } @@ -222,6 +231,13 @@ struct GroupLoggingView: View { private struct Candidate { let device: MetaWearDevice let name: String + /// Live signal when the board is on air; nil for off-air and demo. + let rssi: Int? + /// The distinguishing identity line: MAC when known (remembered + /// record or MAC-broadcast advertisement), else the iOS peripheral + /// identifier — a screen full of boards named "MetaWear" is + /// unpickable without one or the other. + let detail: String } /// Boards eligible for a group: remembered boards (connectable by known @@ -235,7 +251,19 @@ struct GroupLoggingView: View { let localID = appStore.localPeripheralUUID(for: remembered) guard seen.insert(localID).inserted else { continue } let device = appStore.scanner.device(forKnownIdentifier: localID) - result.append(Candidate(device: device, name: remembered.name ?? "MetaWear")) + let isFresh = DeviceFreshness.isFresh( + lastSeen: appStore.scanner.advertisementLastSeen[localID], now: now + ) + result.append(Candidate( + device: device, + name: remembered.name ?? "MetaWear", + // The scanner's RSSI map freezes when a board goes off air — + // only surface it while the advertisement is fresh. + rssi: isFresh ? appStore.scanner.advertisementRSSI[localID] : nil, + detail: remembered.macAddress + ?? appStore.scanner.advertisedMACs[localID] + ?? localID.uuidString + )) } for device in scanVM?.devices ?? [] { guard DeviceFreshness.isFresh( @@ -243,14 +271,22 @@ struct GroupLoggingView: View { ) else { continue } guard seen.insert(device.identifier).inserted else { continue } let name = scanVM?.advertisedName(for: device.identifier) ?? "MetaWear" - result.append(Candidate(device: device, name: name)) + result.append(Candidate( + device: device, + name: name, + rssi: appStore.scanner.advertisementRSSI[device.identifier], + detail: appStore.scanner.advertisedMACs[device.identifier] + ?? device.identifier.uuidString + )) } if DemoMode.isEnabled { for demo in appStore.demoDevices { guard seen.insert(demo.identifier).inserted else { continue } result.append(Candidate( device: demo, - name: DemoMode.name(for: demo.identifier) ?? DemoMode.deviceName + name: DemoMode.name(for: demo.identifier) ?? DemoMode.deviceName, + rssi: nil, + detail: DemoMode.macAddress(for: demo.identifier) ?? demo.identifier.uuidString )) } } diff --git a/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift b/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift index ee6454e..50b4538 100644 --- a/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift +++ b/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift @@ -5,6 +5,17 @@ import MetaWear import MetaWearPersistence @testable import MetaWearApp +/// The board picker's identity line depends on this mapping — a demo MAC +/// must render in the usual big-endian colon form (wire order reversed). +@MainActor +struct DemoModeIdentityTests { + @Test func demoMACRendersBigEndianColonForm() { + #expect(DemoMode.macAddress(for: DemoBLETransport.deviceIdentifier) + == "DE:3D:0E:0D:E0:01") + #expect(DemoMode.macAddress(for: UUID()) == nil) + } +} + /// End-to-end group capture against the simulated fleet: real SDK /// connects, real logger round-trips through `DemoBLETransport`, real /// SwiftData persistence — only the radio is fake. This is the regression From a8951f4432d10134734da9312c60dd7b7547015d Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 17:47:02 -0700 Subject: [PATCH 03/17] Settle the MMS's async page flush before trusting LOG_LENGTH; center the group confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field test (two-board group collect): both boards came back "Some log data could not be decoded" with zero recovered samples. The wire log shows why — the MMS flushes its in-RAM logging page to flash ASYNCHRONOUSLY, and the group flow moves at machine speed: stop, flush command, LOG_LENGTH read within milliseconds. One board read the pre-flush 0 (download short-circuited), the other read a stale page-aligned 512 while its fresh samples were still in RAM. Every human-paced solo flow masked the race with navigation delays between Stop and Download, which is why this never showed before. downloadLogs now waits out the flush: 300 ms head start, then polls LOG_LENGTH until two consecutive reads agree (bounded, ~2 s worst case). No-op on boards without the flush (revision < 3). The demo fleet reports MMS revision, so the E2E group tests exercise the settle path (their timing grew by exactly two settles). Also: the Stop & Download All confirm becomes a centered alert per the app-wide convention — confirmation dialogs anchor as popovers in regular width and pop up wherever their anchor sits (owner feedback). Co-Authored-By: Claude Fable 5 --- .../Features/Logging/GroupLoggingView.swift | 10 +++-- Sources/MetaWear/MetaWearDevice.swift | 39 ++++++++++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 664275a..114a23b 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -58,15 +58,19 @@ struct GroupLoggingView: View { scanVM?.startScan() appStore.refreshPendingLogSessions() } - .confirmationDialog( + // Centered alert, not a confirmation dialog — dialogs anchor as + // popovers in regular width and "pop up anywhere on the page" + // (owner feedback); the app's convention is centered alerts for + // every confirm. + .alert( "Stop logging on all boards and download their data?", - isPresented: $showStopConfirm, - titleVisibility: .visible + isPresented: $showStopConfirm ) { Button("Stop & Download All") { let members = activeGroupMembers Task { await appStore.groupCapture.stopAndDownloadAll(members: members) } } + Button("Cancel", role: .cancel) {} } message: { Text("Each board is collected in turn. Boards that are out of range are skipped — their data stays on the board for later.") } diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index b014240..b4c9328 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -816,7 +816,7 @@ public actor MetaWearDevice { // below reflects every captured sample. Idempotent — safe to call even // if the user already invoked `flushLogPage()` explicitly. No-op on // pre-MMS firmware (revision < 3). - _ = try await flushLogPage() + let didFlush = try await flushLogPage() // Enable readout-notify and progress channels, then read the entry count. try await proto.write(MWPacket.command(.logging, 0x07, [0x01])) // enable readout notify @@ -828,11 +828,7 @@ public actor MetaWearDevice { let pageStream = await proto.subscribe(to: .logging, register: 0x0D) // Read entry count, then start the download - let lengthResponse = try await proto.read(.logging, 0x05) - guard lengthResponse.count >= 6 else { - throw MWError.operationFailed("Log length response too short") - } - let nEntries = MWPacketParser.parseUInt32LE(lengthResponse, offset: 2) + let nEntries = try await settledLogLength(afterFlush: didFlush) // Empty log buffer: short-circuit. Issuing the readout with count=0 // produces no `0x07` raw entries, no `0x0D` page-completed notice, and @@ -1150,6 +1146,37 @@ public actor MetaWearDevice { return true } + /// Read LOG_LENGTH, waiting out the MMS's ASYNCHRONOUS page flush. + /// + /// The flush command returns immediately while the firmware copies the + /// in-RAM partial page to flash — an immediate read races that copy and + /// reports the PRE-flush count. Field evidence (two-board group collect, + /// 2026-07-19): a machine-speed stop→download read 0 entries on one + /// board and a stale page-aligned count on the other; both boards' + /// fresh samples were still in RAM and both downloads came back empty. + /// Human-paced solo flows always masked the race with navigation delays + /// between Stop and Download. Give the flush a head start, then poll + /// until two consecutive reads agree (bounded, ~2 s worst case). + private func settledLogLength(afterFlush didFlush: Bool) async throws -> UInt32 { + func readLength() async throws -> UInt32 { + let response = try await proto.read(.logging, 0x05) + guard response.count >= 6 else { + throw MWError.operationFailed("Log length response too short") + } + return MWPacketParser.parseUInt32LE(response, offset: 2) + } + guard didFlush else { return try await readLength() } + try await Task.sleep(for: .milliseconds(300)) + var previous = try await readLength() + for _ in 0..<6 { + try await Task.sleep(for: .milliseconds(250)) + let current = try await readLength() + if current == previous { return current } + previous = current + } + return previous + } + // MARK: - Logger recovery /// Timeout for slot-enumeration probes (`queryActiveLoggers` / From 295c8770d2573f806907dc1e9c15b011664d3c9b Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 18:04:01 -0700 Subject: [PATCH 04/17] Board picker names render in label black, not the button tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback: the device names in the group picker showed orange. The rows are Form buttons, and a button tints its label — hierarchical .primary resolves against that tint (brand orange), not label black. Plain button style restores real label colors; the explicit styles (accent checkmark, warning captions, blue RSSI pill) are unaffected, and the row keeps a full-width contentShape so taps don't dead-zone. Co-Authored-By: Claude Fable 5 --- .../MetaWear/Features/Logging/GroupLoggingView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 114a23b..59e2541 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -195,7 +195,14 @@ struct GroupLoggingView: View { RSSIPill(dBm: rssi) } } + // Row taps must span the whole cell, not just the + // rendered text (same trap as the activity tiles). + .contentShape(.rect) } + // Plain style, or the Form button tints the label and + // the name renders brand-orange: hierarchical .primary + // resolves against the button's tint, not label black. + .buttonStyle(.plain) } } } header: { From 40be9712a9a037354e01eff69ffeb00bf98d4063 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 18:11:28 -0700 Subject: [PATCH 05/17] Flush the MMS page while logging is live; wait out a zero that can't be true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second field test: the settle poll ran (two LOG_LENGTH reads visible on the wire) and both reads agreed on ZERO on both boards — so this is not a read race. The flush command isn't landing the RAM page in flash in under a second, or isn't honoured at all in the post-stop state we send it in. Wire-level audit against ground truth (C++ SDK logging.cpp, Combine SDK Download.swift, the register spec): our bytes are identical — [0x0B, 0x10, 0x01] after logging-stop — and the spec gives the flush no ack and no timing contract. The canonical apps were always driven at human pace between Stop and Download, so a slow or state-sensitive flush would never have shown there either; short sub-page sessions may never have downloaded reliably on MMS anywhere. Three changes, all belt-and-braces safe: - stopLogging (both overloads) flushes BEFORE disabling the logging module — a write-cache flush is only known to be honoured with the module live. downloadLogs still flushes after, as before. - settledLogLength becomes expectation-aware: when the caller KNOWS the board logged (records exist / it reported itself logging), a stable zero is treated as "flush hasn't landed", not "board is empty" — it keeps polling up to ~8 s, re-issuing the (ack-less) flush every few attempts. Without the expectation, the old quick settle applies. - Every settle read is now mwLog'd, so the next field log shows the exact LENGTH curve over time — if the board still reports zero after flush-before-stop + 8 s of polling, that's a firmware answer, not a timing one. DownloadViewModel passes the expectation from both paths (pending records; foreign entryCount/active). 1058 SPM + full app suite pass. Co-Authored-By: Claude Fable 5 --- .../ViewModels/DownloadViewModel.swift | 13 ++++-- Sources/MetaWear/MetaWearDevice.swift | 41 +++++++++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 0da4e5c..e5fbbf8 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -89,7 +89,7 @@ final class DownloadViewModel { // stream together; per-record dispatch happens after. let allEntries: [RawLogEntry] do { - allEntries = try await drainRawDownload() + allEntries = try await drainRawDownload(expectEntries: true) } catch { phase = .failed(message: error.localizedDescription) lastError = AppError(error: error) @@ -187,7 +187,9 @@ final class DownloadViewModel { return } - let entries = try await drainRawDownload() + let entries = try await drainRawDownload( + expectEntries: state.entryCount > 0 || state.isActivelyLogging + ) guard let info = await device.deviceInfo else { throw MWError.invalidState("Device info unavailable") } @@ -227,8 +229,11 @@ final class DownloadViewModel { /// Drain `device.downloadLogs()` into a single accumulated entries array, /// updating `phase` with the percentage as the download progresses. - private func drainRawDownload() async throws -> [RawLogEntry] { - let stream = try await device.downloadLogs() + /// - Parameter expectEntries: the caller knows the board logged — + /// passes through to the SDK's flush-settle logic so a slow NAND + /// flush waits instead of yielding an instant empty download. + private func drainRawDownload(expectEntries: Bool = false) async throws -> [RawLogEntry] { + let stream = try await device.downloadLogs(expectEntries: expectEntries) var all: [RawLogEntry] = [] var sawCompletion = false for try await chunk in stream { diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index b4c9328..2f580ee 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -607,6 +607,11 @@ public actor MetaWearDevice { // still needs its own stop + disable writes — otherwise the board // keeps sampling that sensor (and `downloadLogs` returns no entries // because the logger never sees a fresh session marker). + // Flush the in-RAM partial page WHILE logging is still enabled — + // the NAND write-cache flush is only known to be honoured with the + // module live; downloadLogs flushes again afterwards as belt and + // braces. No-op on non-MMS boards. + _ = try? await flushLogPage() try await proto.write(MWPacket.command(.logging, 0x01, [0x00])) // stop logging for cmd in loggable.stopCommands where !cmd.isEmpty { try await proto.write(cmd) } for cmd in loggable.disableCommands where !cmd.isEmpty { try await proto.write(cmd) } @@ -741,6 +746,7 @@ public actor MetaWearDevice { try? await stopTimer(timer) try? await removeTimer(timer) try? await removeEvent(MWEvent(id: handles.eventID)) + _ = try? await flushLogPage() // see MWLoggable overload try await proto.write(MWPacket.command(.logging, 0x01, [0x00])) state = .idle } @@ -804,7 +810,11 @@ public actor MetaWearDevice { /// data. We force-flush the active page here so that workflow shape always /// works without the caller having to remember `flushLogPage()`. The flush /// is a no-op on MMRL (logging revision < 3). - public func downloadLogs() async throws -> AsyncThrowingStream, Error> { + /// - Parameter expectEntries: pass true when the caller KNOWS the board + /// logged (local records exist, or it reported itself logging) — a + /// zero LOG_LENGTH then triggers a longer flush-settle wait instead of + /// an instant empty download. + public func downloadLogs(expectEntries: Bool = false) async throws -> AsyncThrowingStream, Error> { mwLog("[Device] downloadLogs") guard case .idle = state else { throw MWError.invalidState("Device must be idle to download") @@ -828,7 +838,7 @@ public actor MetaWearDevice { let pageStream = await proto.subscribe(to: .logging, register: 0x0D) // Read entry count, then start the download - let nEntries = try await settledLogLength(afterFlush: didFlush) + let nEntries = try await settledLogLength(afterFlush: didFlush, expectEntries: expectEntries) // Empty log buffer: short-circuit. Issuing the readout with count=0 // produces no `0x07` raw entries, no `0x0D` page-completed notice, and @@ -1157,7 +1167,7 @@ public actor MetaWearDevice { /// Human-paced solo flows always masked the race with navigation delays /// between Stop and Download. Give the flush a head start, then poll /// until two consecutive reads agree (bounded, ~2 s worst case). - private func settledLogLength(afterFlush didFlush: Bool) async throws -> UInt32 { + private func settledLogLength(afterFlush didFlush: Bool, expectEntries: Bool) async throws -> UInt32 { func readLength() async throws -> UInt32 { let response = try await proto.read(.logging, 0x05) guard response.count >= 6 else { @@ -1168,13 +1178,30 @@ public actor MetaWearDevice { guard didFlush else { return try await readLength() } try await Task.sleep(for: .milliseconds(300)) var previous = try await readLength() - for _ in 0..<6 { - try await Task.sleep(for: .milliseconds(250)) + mwLog("[Device] settledLogLength: read \(previous)") + var attempt = 0 + while true { + attempt += 1 + // Two consecutive agreeing reads normally settle it — but when + // the caller KNOWS the board logged (records exist / it reported + // itself logging), a stable ZERO means the NAND flush hasn't + // landed yet, not that the board is empty. Keep waiting (and + // periodically re-issue the flush; it has no ack) up to ~8 s + // before conceding — an empty download here reads as data loss + // to the user, so patience is the cheaper failure mode. + let expectingMore = expectEntries && previous == 0 + let maxAttempts = expectingMore ? 10 : 6 + guard attempt <= maxAttempts else { return previous } + if expectingMore, attempt.isMultiple(of: 3) { + mwLog("[Device] settledLogLength: re-issuing flush") + try await proto.write(MWPacket.command(.logging, 0x10, [0x01])) + } + try await Task.sleep(for: .milliseconds(expectingMore ? 750 : 250)) let current = try await readLength() - if current == previous { return current } + mwLog("[Device] settledLogLength: read \(current)") + if current == previous, !(expectEntries && current == 0) { return current } previous = current } - return previous } // MARK: - Logger recovery From 9e220519c3d5426bbce26e96345f39f170640d44 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 18:20:57 -0700 Subject: [PATCH 06/17] Group boards blink a slow red heartbeat while recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request. On group start, each successfully started board gets a red LED heartbeat (100 ms pulse every 5 s — slow and dim-duty so a multi-hour log doesn't pay for it): LED playback runs on the board, so the heartbeat survives the disconnect and a glance at the fleet shows which pucks are recording. The collect pass clears the LED the moment it takes a board, before stop/download, so the state can't outlive the session even on a failed download. Both are best-effort — an LED hiccup never fails a pass. Group flow only; solo logging untouched. Co-Authored-By: Claude Fable 5 --- .../ViewModels/GroupCaptureCoordinator.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift index 80e60f8..ee6f485 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -76,6 +76,17 @@ final class GroupCaptureCoordinator { private let persistence: MWPersistenceStore private unowned let appStore: AppStore + /// The on-board "recording" heartbeat: a short red pulse every 5 s, + /// played while a group session logs. LED playback runs on the board + /// itself, so the heartbeat survives the disconnect — a glance at the + /// fleet shows which pucks are recording. Slow + dim-duty on purpose: + /// the LED must not meaningfully dent a multi-hour logging battery. + static let recordingHeartbeat = MWLEDPattern( + highIntensity: 31, lowIntensity: 0, + riseTime: 0, highTime: 100, fallTime: 0, + pulseDuration: 5000, repeatCount: .max + ) + /// How long a sequential pass waits for one board's connect before /// declaring it absent and moving on. CoreBluetooth itself never times /// out a connect — without this cap one missing board wedges the whole @@ -123,6 +134,10 @@ final class GroupCaptureCoordinator { let vm = LogSessionViewModel(device: member.device, containers: containers) await vm.start(selections, groupID: groupID) if case .running = vm.phase { + // Best-effort: the heartbeat is a courtesy indicator — + // an LED hiccup must not fail a successfully started + // board. + try? await member.device.setLED(red: Self.recordingHeartbeat) setPhase(id, .logging) } else { setPhase(id, .failed(vm.lastError?.message ?? "Logging did not start")) @@ -172,6 +187,10 @@ final class GroupCaptureCoordinator { /// in the board's phase so one board's failure can't abort the walk. private func collectOne(member: Member) async { let id = member.device.identifier + // Recording is over the moment collection begins — clear the + // heartbeat first so the LED state can't outlive the session even + // if the download below fails. Best-effort, like the set. + try? await member.device.stopLED() let pending = appStore.pendingLogSessions.filter { $0.deviceID == id } let running = pending.filter { $0.status == .running } From e66dcb6a09c6079f570d8ec80e50035a4079373d Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 18:25:02 -0700 Subject: [PATCH 07/17] Retry failed group downloads in place; drop Add To Group for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner requests. A failed download keeps the board's data, so the Last Run section now offers Retry Failed Downloads right where the failure shows — it re-collects just the failed boards (device handles resolve demo-fleet-first). Retry only appears after a COLLECT pass: a failed START is re-attempted by simply selecting the boards again. Add To Group is removed: while a group is live the screen shows only the group's state and its collect button; the picker returns once the group is collected. (The capability lives in git history if it earns its way back.) Co-Authored-By: Claude Fable 5 --- .../Features/Logging/GroupLoggingView.swift | 40 +++++++++++++------ .../ViewModels/GroupCaptureCoordinator.swift | 19 ++++----- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 59e2541..275e812 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -35,11 +35,7 @@ struct GroupLoggingView: View { if !active.isEmpty { activeGroupSection(records: active) - } - // The picker stays reachable while a group is live so a - // board that was out of range during the first pass can - // still be added — its sessions join the SAME batch. - if !coordinator.isBusy { + } else if !coordinator.isBusy { boardPickerSection(now: timeline.date) SensorPickerSection( selections: $selections, @@ -48,7 +44,7 @@ struct GroupLoggingView: View { supportedKinds: Self.groupLoggableKinds, isLocked: false ) - startSection(now: timeline.date, joining: active.first?.groupID) + startSection(now: timeline.date) } } } @@ -103,6 +99,27 @@ struct GroupLoggingView: View { } } } + // A failed download keeps the board's data — offer the retry + // right where the failure is shown. Rebuilds members from the + // failed rows (device handles resolve demo-fleet-first). + let failed = coordinator.boards.filter { + if case .failed = $0.phase { return true } + return false + } + if !coordinator.isBusy, coordinator.lastPass == .collect, !failed.isEmpty { + Button { + let members = failed.map { + GroupCaptureCoordinator.Member( + device: resolveDevice(for: $0.id), + name: $0.name + ) + } + Task { await appStore.groupCapture.stopAndDownloadAll(members: members) } + } label: { + Label("Retry Failed Download\(failed.count == 1 ? "" : "s")", + systemImage: "arrow.clockwise") + } + } } header: { Text(coordinator.isBusy ? "Working…" : "Last Run") } footer: { @@ -215,25 +232,22 @@ struct GroupLoggingView: View { /// Members, footer count, and enablement all derive from ONE live /// computation — a board that went off-air after being checked must /// not be silently dropped while the footer still counts it. - private func startSection(now: Date, joining groupID: UUID?) -> some View { + private func startSection(now: Date) -> some View { let members = selectedMembers(now: now) return Section { Button { Task { - await appStore.groupCapture.startAll( - members: members, selections: selections, joining: groupID - ) + await appStore.groupCapture.startAll(members: members, selections: selections) } } label: { - Label(groupID == nil ? "Start Logging All" : "Add To Group", - systemImage: "record.circle.fill") + Label("Start Logging All", systemImage: "record.circle.fill") .font(.body.weight(.semibold)) } .disabled(members.isEmpty || selections.isEmpty || appStore.groupCapture.isBusy) } footer: { Text(members.isEmpty ? "Select at least one board above." - : "\(members.count) board\(members.count == 1 ? "" : "s") will start logging\(groupID == nil ? "" : " and join the group").") + : "\(members.count) board\(members.count == 1 ? "" : "s") will start logging.") } } diff --git a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift index ee6f485..0497a76 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -64,7 +64,13 @@ final class GroupCaptureCoordinator { // MARK: - Observable state + enum PassKind { case start, collect } + private(set) var boards: [BoardProgress] = [] + /// Which pass produced `boards` — drives the post-pass affordances + /// (a failed COLLECT offers Retry; a failed start does not, since the + /// user can simply select those boards and start again). + private(set) var lastPass: PassKind? /// True while a start or collect pass is walking the fleet. private(set) var isBusy = false /// The download engine for whichever board is currently draining — @@ -106,19 +112,13 @@ final class GroupCaptureCoordinator { /// `LogSessionRecord`s → disconnect. Boards that already carry a /// pending session are skipped — starting over it would fight the /// existing session for logger slots. - /// - Parameter existingGroupID: pass the live group's ID to ADD boards - /// to it (e.g. a member that was out of range during the first pass) - /// instead of minting a new batch. - func startAll( - members: [Member], - selections: [SensorSelection], - joining existingGroupID: UUID? = nil - ) async { + func startAll(members: [Member], selections: [SensorSelection]) async { guard !isBusy, !members.isEmpty, !selections.isEmpty else { return } isBusy = true + lastPass = .start defer { isBusy = false } - let groupID = existingGroupID ?? UUID() + let groupID = UUID() boards = members.map { BoardProgress(id: $0.device.identifier, name: $0.name) } for member in members { @@ -162,6 +162,7 @@ final class GroupCaptureCoordinator { func stopAndDownloadAll(members: [Member]) async { guard !isBusy, !members.isEmpty else { return } isBusy = true + lastPass = .collect defer { isBusy = false activeDownload = nil From 6c0c753c222ea15d097de39049291db431725751 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 18:29:11 -0700 Subject: [PATCH 08/17] Group start clears stale boards; partial saves stop posing as failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third field test told the whole story. Run 1: boards still carrying stale trigger slots from earlier abandoned sessions read LOG_LENGTH 0 FOREVER — through flush-before-stop, the 8 s settle, and three flush re-issues. The owner then cleared both boards from Settings, re-ran, and run 2 WORKED: one board read a stable 7232 entries, streamed 15 pages, decoded both sensors, and cleared; the other read 3072 and streamed 6 pages. Stale on-board state — not timing — is what wedges the MMS flush/LENGTH path. - startAll therefore clears every board before starting its loggers. Boards with local pending sessions are already skipped above, so anything reaching the clear has no local claim; unclaimed foreign data is wiped by design — the user just chose to record fresh. - The second board's residual issue was a DECODE gap: the drain succeeded but one of two records decoded empty, which rendered as a hard failure and offered Retry — and the retry honestly read 0, because the readout had already consumed (page-confirm-nulled) the entries. New phase .savedWithIssues(count, warning): partial saves render as a warning-tinged success, and Retry is only offered for true failures (connect/drain errors) where it can actually work. - The keptBoardData warning no longer promises a retry the drain made impossible. The one-record decode gap on the BMI270 board is still open — needs the Session History contents from run 2 to identify which sensor's record came up empty. Co-Authored-By: Claude Fable 5 --- .../Features/Logging/GroupLoggingView.swift | 5 ++++ .../ViewModels/DownloadViewModel.swift | 2 +- .../ViewModels/GroupCaptureCoordinator.swift | 26 +++++++++++++++---- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 275e812..b545977 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -396,6 +396,9 @@ struct GroupLoggingView: View { Image(systemName: "record.circle.fill").foregroundStyle(Palette.danger) case .saved: Image(systemName: "checkmark.circle.fill").foregroundStyle(Palette.success) + case .savedWithIssues: + Image(systemName: "checkmark.circle.trianglebadge.exclamationmark") + .foregroundStyle(Palette.warning) case .skipped: Image(systemName: "minus.circle").foregroundStyle(Palette.warning) case .failed: @@ -412,6 +415,8 @@ struct GroupLoggingView: View { case .stopping: return "Stopping…" case .downloading: return "Downloading…" case .saved(let count): return "Saved \(count) session\(count == 1 ? "" : "s")" + case .savedWithIssues(let count, let warning): + return "Saved \(count) session\(count == 1 ? "" : "s") · \(warning)" case .skipped(let reason): return reason case .failed(let message): return message } diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index e5fbbf8..1925583 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -124,7 +124,7 @@ final class DownloadViewModel { if keptBoardData { phase = .ready( snapshots: snapshots, - warning: "Some log data could not be decoded. Board data was kept so you can retry Download or clear it from Settings." + warning: "Some sensors' data could not be decoded; everything decodable was saved." ) return } diff --git a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift index 0497a76..69f0e96 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -35,6 +35,10 @@ final class GroupCaptureCoordinator { case downloading /// Collect pass succeeded; the payload is the saved session count. case saved(Int) + /// The drain succeeded and SOME sessions saved, but not all records + /// decoded. No Retry is offered: the readout already consumed the + /// board's entries, so a re-download cannot recover the rest. + case savedWithIssues(Int, String) /// The board was left untouched, with the reason (out of range, /// nothing to download, …). Not an error: absent boards can be /// collected individually later via the normal per-board flow. @@ -43,7 +47,7 @@ final class GroupCaptureCoordinator { var isTerminal: Bool { switch self { - case .logging, .saved, .skipped, .failed: return true + case .logging, .saved, .savedWithIssues, .skipped, .failed: return true default: return false } } @@ -131,6 +135,15 @@ final class GroupCaptureCoordinator { setPhase(id, .connecting) let ownsConnection = try await connectIfNeeded(member.device) setPhase(id, .starting) + // Boards reaching here have no local claim on their flash + // (pending boards were skipped above). Field evidence: MMS + // boards carrying stale trigger slots from abandoned + // sessions read LOG_LENGTH 0 FOREVER after logging (flush + // and all) — the same boards download fine after a clear. + // Start every group session on a clean slate. This also + // wipes unclaimed foreign data by design: the user just + // chose to record fresh on this board. + try? await member.device.clearLog() let vm = LogSessionViewModel(device: member.device, containers: containers) await vm.start(selections, groupID: groupID) if case .running = vm.phase { @@ -237,10 +250,13 @@ final class GroupCaptureCoordinator { switch download.phase { case .ready(let snapshots, let warning): if let warning { - // Partial success (some records undecodable, board data - // kept, …) must not render a green check — the warning is - // the actionable truth and the board needs a retry. - setPhase(id, .failed(warning)) + // Partial success must not render an unqualified green + // check — but when sessions DID save, it isn't a failure + // either, and a Retry can't help (the drain consumed the + // board's entries). + setPhase(id, snapshots.isEmpty + ? .failed(warning) + : .savedWithIssues(snapshots.count, warning)) } else { setPhase(id, .saved(snapshots.count)) } From 0fb66d6d0040a67e70455f59ac1d69c58f017023 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 19:49:41 -0700 Subject: [PATCH 09/17] Download paths narrate their decode outcomes to the debug console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third field test left an ambiguity the wire log can't resolve: one board's collect took the kept-data path (no clearLog on the wire), yet the owner reports both sensors present in Session History — which may include sessions from older runs. Rather than diagnosing by timestamp archaeology, the download engine now narrates itself: the drained entry count with its per-logger-id distribution, each record's outcome (saved N samples / decoded EMPTY / threw), and each foreign signal's sample count. Mirrors the SDK's mwLog (stderr, DEBUG-only) so the lines land in the same console as the wire log. Co-Authored-By: Claude Fable 5 --- .../ViewModels/DownloadViewModel.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 1925583..2cce1ff 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -35,6 +35,15 @@ final class DownloadViewModel { var phase: Phase = .idle var lastError: AppError? + /// Mirrors the SDK's `mwLog` (stderr, DEBUG only) so decode outcomes + /// land in the same console as the wire log — field diagnosis of + /// "which record came up empty" must not require a debugger. + private static func debugLog(_ message: @autoclosure () -> String) { + #if DEBUG + fputs("[Download] \(message())\n", stderr) + #endif + } + init(device: MetaWearDevice, store: MWPersistenceStore, containers: AppContainers, deviceName: String? = nil) { self.device = device @@ -90,6 +99,12 @@ final class DownloadViewModel { let allEntries: [RawLogEntry] do { allEntries = try await drainRawDownload(expectEntries: true) + let perLogger = Dictionary(grouping: allEntries, by: \.id) + .mapValues(\.count) + .sorted { $0.key < $1.key } + .map { "id \($0.key): \($0.value)" } + .joined(separator: ", ") + Self.debugLog("drained \(allEntries.count) entries [\(perLogger)]") } catch { phase = .failed(message: error.localizedDescription) lastError = AppError(error: error) @@ -107,13 +122,16 @@ final class DownloadViewModel { info: info, entries: allEntries ) { + Self.debugLog("\(record.sensorKind): saved \(snap.sampleCount) samples") snapshots.append(snap) record.status = .downloaded } else { + Self.debugLog("\(record.sensorKind): decoded EMPTY — record kept") record.status = .stopped keptBoardData = true } } catch { + Self.debugLog("\(record.sensorKind): decode/save FAILED — \(error)") record.status = .stopped keptBoardData = true lastError = AppError(error: error) @@ -197,6 +215,7 @@ final class DownloadViewModel { var snapshots: [MWSessionSnapshot] = [] for signal in signals { let samples = try await device.decodeEntries(entries, for: signal) + Self.debugLog("foreign \(signal.identifier): \(samples.count) samples") if let snap = try await save(anonymousSignal: signal, samples: samples, info: info) { snapshots.append(snap) } From 6159abbfc07099e07bc93c42691813516a13b084 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 19:58:26 -0700 Subject: [PATCH 10/17] Wait for the firmware's Drop Entries completion before arming loggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth field test falsified the naive clean-slate fix in the most useful way: the auto-clear IS the new failure. The wire shows why — clearLog fires Drop Entries and returns immediately, and the group start arms new loggers milliseconds later. On MMS NAND the drop kicks off page garbage collection that grinds for seconds; a session started under it records NOTHING (LENGTH honestly 0, drained 0 entries — the new [Download] diagnostics confirmed no logger produced a single entry). The owner's manual clear worked purely because minutes of human pacing passed before the next start. The firmware documents the handshake we were ignoring: the spec's register table says READOUT_PAGE_COMPLETED (0x0D) is "sent … after the Drop Entries command completes". clearLog now enables that notify, drops, and WAITS for the completion (MMS-revision boards only; bounded at 30 s with a logged best-effort fallback so a silent board can't wedge callers). The demo transport mirrors the notification, so the E2E group tests exercise the handshake. Also from this run: the LED heartbeat's pattern + play bytes are byte-for-byte correct on the wire — the board simply stops LED playback on disconnect. Surviving that needs the on-board disconnect-event mechanism; parked as a follow-up. Co-Authored-By: Claude Fable 5 --- Sources/MetaWear/MetaWearDevice.swift | 48 +++++++++++++++++++ .../MetaWear/Transport/DemoBLETransport.swift | 7 +++ 2 files changed, 55 insertions(+) diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index 2f580ee..dccd02e 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -1116,11 +1116,59 @@ public actor MetaWearDevice { throw MWError.invalidState("Device must be idle to clear the log") } try await proto.write(MWPacket.command(.logging, 0x01, [0x00])) // stop logging + + // The drop is ASYNCHRONOUS on MMS NAND: it kicks off page garbage + // collection that grinds for seconds, and the firmware signals + // completion with a READOUT_PAGE_COMPLETED (0x0D) notification — + // the spec's register table: "sent … after the Drop Entries + // command completes". Arming a new session before that lands + // records NOTHING (field evidence: a machine-speed clear→start + // logged zero entries on both boards; the same boards logged fine + // after a human-paced clear). MMS-revision boards therefore wait + // for the completion, bounded — if a board never signals we + // proceed best-effort rather than wedge every caller. + let awaitCompletion = (modules[.logging]?.revision ?? 0) >= 3 + var pageStream: AsyncThrowingStream? + if awaitCompletion { + try await proto.write(MWPacket.command(.logging, 0x0D, [0x01])) + pageStream = await proto.subscribe(to: .logging, register: 0x0D) + } + try await proto.write(MWPacket.command(.logging, 0x09, [0xFF, 0xFF, 0xFF, 0xFF])) + + if let pageStream { + let completed = await Self.awaitFirstElement(of: pageStream, timeout: .seconds(30)) + mwLog(completed + ? "[Device] clearLog: drop completed" + : "[Device] clearLog: drop completion TIMED OUT — proceeding") + await proto.unsubscribe(from: .logging, register: 0x0D) + try? await proto.write(MWPacket.command(.logging, 0x0D, [0x00])) + } + try await proto.write(MWPacket.command(.logging, 0x0A, [])) // remove all loggers loggerRegistry.removeAll() } + /// Wait for the first element of `stream`, bounded by `timeout`. + /// - Returns: true when an element arrived before the deadline. + private static func awaitFirstElement( + of stream: AsyncThrowingStream, timeout: Duration + ) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + return (try? await iterator.next()) != nil + } + group.addTask { + try? await Task.sleep(for: timeout) + return false + } + let first = await group.next() ?? false + group.cancelAll() + return first + } + } + /// Stop on-board logging sampling (`[0x0B, 0x01, 0x00]`) WITHOUT /// touching stored entries or logger subscriptions. /// diff --git a/Sources/MetaWear/Transport/DemoBLETransport.swift b/Sources/MetaWear/Transport/DemoBLETransport.swift index 8a816bd..45b5631 100644 --- a/Sources/MetaWear/Transport/DemoBLETransport.swift +++ b/Sources/MetaWear/Transport/DemoBLETransport.swift @@ -199,6 +199,13 @@ public actor DemoBLETransport: BLETransport { storedEntryCount = 0 loggingStarted = loggingEnabled ? .now : nil if register == 0x0A { loggers.removeAll(); nextLoggerID = 0 } + // Firmware signals Drop Entries completion with a page-completed + // notification (spec: 0x0D "sent … after the Drop Entries + // command completes") — clearLog waits for it on MMS-revision + // boards, and the demo reports MMS revision. + if register == 0x09, subscriptions.contains(.init(module: 0x0B, register: 0x0D)) { + emit([0x0B, 0x0D]) + } // ---- Timer / Event / Macro allocation ---- case (0x0C, 0x02): From d8d1dd28c98a4f3ae7ce2121340647c020ff8f25 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:09:18 -0700 Subject: [PATCH 11/17] Each sensor picks its variant from ITS OWN module byte; clear waits smarter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth field test, mixed fleet (MMS + MMRL, shared accel+gyro config), with the new decode diagnostics finally naming names: - MMS: drained [id 0: 2361, id 1: 2361] — accel perfect, gyro loggers produced ZERO entries on the board. Cause: the app derived the gyro variant from the ACCELEROMETER's chip generation, arming the gyro trigger on the wrong data register for that board's gyro. The C++ SDK selects every sensor by its own module's implementation byte (module_info.at(MODULE).implementation) — now we do too: new SensorImplementations(modules:) reads accel/gyro impl bytes straight from discovery (values match MBL_MW_MODULE_ACC_TYPE_* and GYRO_TYPE_* exactly), threaded through start, stop, rollback, recovery, and decode in both view models. - MMRL: its Drop Entries never signalled completion and outlived the 30 s bound (dirty NAND, long GC); loggers armed onto the grinding board — only one of four ever wrote. clearLog now waits on TWO independent completion signals, first one wins: the documented 0x0D notification OR LOG_LENGTH reading zero on consecutive polls, with the bound raised to 60 s. - startLogging now logs the firmware-assigned logger ids and recoverLoggers the matched ids, so the next field log shows the id chain end to end. Still open: LED heartbeat dies with the link (needs the on-board disconnect-event mechanism); one anomalous "Packet too short for CartesianFloat: 6 bytes" decode error seen only in the crippled single-logger board state the fixes above prevent. Co-Authored-By: Claude Fable 5 --- .../ViewModels/DownloadViewModel.swift | 32 +++++----- .../ViewModels/LogSessionViewModel.swift | 63 ++++++++++++------- Sources/MetaWear/MetaWearDevice.swift | 39 +++++++++++- 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 2cce1ff..5cde696 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -70,7 +70,7 @@ final class DownloadViewModel { phase = .downloading(progress: 0, downloaded: 0, total: 0) let modules = await device.modules - let chip = MWSensorFusionChip(accImpl: modules[.accelerometer]?.implementation ?? 1) ?? .bmi160 + let impls = SensorImplementations(modules: modules) guard let info = await device.deviceInfo else { phase = .failed(message: "Device info unavailable") return @@ -91,7 +91,7 @@ final class DownloadViewModel { return } for record in records { - await recoverLoggers(for: record, chip: chip, active: activeLoggers) + await recoverLoggers(for: record, impls: impls, active: activeLoggers) } // 2. ONE raw download. Entries from every logger come through this @@ -118,7 +118,7 @@ final class DownloadViewModel { do { if let snap = try await decodeAndSave( record: record, - chip: chip, + impls: impls, info: info, entries: allEntries ) { @@ -386,7 +386,7 @@ final class DownloadViewModel { /// (`MWLoggable` or `MWPolledLogger`) corresponds to the record. Silently /// ignores errors — they'll resurface meaningfully when `decodeAndSave` /// can't find the chunks in the registry. - private func recoverLoggers(for record: LogSessionRecord, chip: MWSensorFusionChip, + private func recoverLoggers(for record: LogSessionRecord, impls: SensorImplementations, active: [ActiveLogger]) async { guard let selection = LogSessionViewModel.decode(record.configJSON, kind: record.sensorKind) else { return } switch selection.id { @@ -403,7 +403,7 @@ final class DownloadViewModel { ) try? await device.recoverLoggers(for: polled, using: active) default: - if let loggable = LogSessionViewModel.makeLoggable(for: selection, chip: chip) { + if let loggable = LogSessionViewModel.makeLoggable(for: selection, impls: impls) { try? await device.recoverLoggers(for: loggable, using: active) } } @@ -411,7 +411,7 @@ final class DownloadViewModel { private func decodeAndSave( record: LogSessionRecord, - chip: MWSensorFusionChip, + impls: SensorImplementations, info: MWDeviceInformation, entries: [RawLogEntry] ) async throws -> MWSessionSnapshot? { @@ -424,8 +424,7 @@ final class DownloadViewModel { switch selection.id { case .accelerometer: let rangeG = Float(selection.range ?? 2) - let impl: UInt8 = chip == .bmi270 ? 4 : 1 - switch MWAccelerometer.make(impl: impl, odrHz: selection.hz, rangeG: rangeG) { + switch MWAccelerometer.make(impl: impls.accel, odrHz: selection.hz, rangeG: rangeG) { case .bmi160(let s)?: return try await decodeAndPersist(s, info: info, label: label, entries: entries, groupID: groupID) case .bmi270(let s)?: return try await decodeAndPersist(s, info: info, label: label, entries: entries, groupID: groupID) case nil: return nil @@ -433,8 +432,7 @@ final class DownloadViewModel { case .gyroscope: let rangeDPS = Float(selection.range ?? 2000) - let impl: UInt8 = chip == .bmi270 ? 1 : 0 - switch MWGyroscope.make(impl: impl, odrHz: selection.hz, rangeDPS: rangeDPS) { + switch MWGyroscope.make(impl: impls.gyro, odrHz: selection.hz, rangeDPS: rangeDPS) { case .bmi160(let s)?: return try await decodeAndPersist(s, info: info, label: label, entries: entries, groupID: groupID) case .bmi270(let s)?: return try await decodeAndPersist(s, info: info, label: label, entries: entries, groupID: groupID) case nil: return nil @@ -450,25 +448,25 @@ final class DownloadViewModel { case .sensorFusion(let out): switch out { case .quaternion: - return try await decodeAndPersist(MWSensorFusionQuaternion(chip: chip), + return try await decodeAndPersist(MWSensorFusionQuaternion(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) case .eulerAngles: - return try await decodeAndPersist(MWSensorFusionEuler(chip: chip), + return try await decodeAndPersist(MWSensorFusionEuler(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) case .gravity: - return try await decodeAndPersist(MWSensorFusionGravity(chip: chip), + return try await decodeAndPersist(MWSensorFusionGravity(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) case .linearAcceleration: - return try await decodeAndPersist(MWSensorFusionLinearAcceleration(chip: chip), + return try await decodeAndPersist(MWSensorFusionLinearAcceleration(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) case .correctedAcceleration: - return try await decodeAndPersist(MWSensorFusionCorrectedAcc(chip: chip), + return try await decodeAndPersist(MWSensorFusionCorrectedAcc(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) case .correctedAngularVelocity: - return try await decodeAndPersist(MWSensorFusionCorrectedGyro(chip: chip), + return try await decodeAndPersist(MWSensorFusionCorrectedGyro(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) case .correctedMagneticField: - return try await decodeAndPersist(MWSensorFusionCorrectedMag(chip: chip), + return try await decodeAndPersist(MWSensorFusionCorrectedMag(chip: impls.fusionChip), info: info, label: label, entries: entries, groupID: groupID) } diff --git a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift index 6ea2bd6..62a1f92 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift @@ -3,6 +3,25 @@ import Observation import SwiftData import MetaWear +/// Per-module hardware implementations, read from module discovery. Each +/// sensor variant MUST be selected by its own module's implementation byte +/// (mirroring the C++ SDK's `module_info.at(MODULE).implementation`) — +/// deriving the gyro from the ACCEL chip armed the wrong gyro data +/// register on mixed-generation boards, so its loggers never fired. +/// Field evidence: an MMS + MMRL group logged accel fine on both, gyro on +/// neither-or-one depending on which board the guess happened to match. +struct SensorImplementations { + let accel: UInt8 + let gyro: UInt8 + let fusionChip: MWSensorFusionChip + + init(modules: [MWModule: MWModuleInfo]) { + accel = modules[.accelerometer]?.implementation ?? 1 + gyro = modules[.gyro]?.implementation ?? 0 + fusionChip = MWSensorFusionChip(accImpl: accel) ?? .bmi160 + } +} + /// Drives on-device logging setup and teardown. /// /// Converts selected UI sensors into SDK loggers, persists pending @@ -42,11 +61,11 @@ final class LogSessionViewModel { } let context = containers.local.mainContext let modules = await device.modules - let chip = MWSensorFusionChip(accImpl: modules[.accelerometer]?.implementation ?? 1) ?? .bmi160 + let impls = SensorImplementations(modules: modules) var records: [LogSessionRecord] = [] do { for selection in selections { - if let record = try await startOne(selection: selection, chip: chip, context: context, groupID: groupID) { + if let record = try await startOne(selection: selection, impls: impls, context: context, groupID: groupID) { records.append(record) } } @@ -55,7 +74,7 @@ final class LogSessionViewModel { phase = .running(startedAt: .now) startElapsedTimer() } catch { - let stillRunning = await rollbackStartedRecords(records, chip: chip, context: context) + let stillRunning = await rollbackStartedRecords(records, impls: impls, context: context) if stillRunning.isEmpty { activeRecords = [] phase = .idle @@ -78,7 +97,7 @@ final class LogSessionViewModel { /// way. Returns nil if the sensor can't be logged on this board. private func startOne( selection: SensorSelection, - chip: MWSensorFusionChip, + impls: SensorImplementations, context: ModelContext, groupID: UUID? = nil ) async throws -> LogSessionRecord? { @@ -121,7 +140,7 @@ final class LogSessionViewModel { return record default: - guard let loggable = Self.makeLoggable(for: selection, chip: chip) else { return nil } + guard let loggable = Self.makeLoggable(for: selection, impls: impls) else { return nil } try await device.startLogging(loggable) let record = LogSessionRecord( deviceID: device.identifier, @@ -141,7 +160,7 @@ final class LogSessionViewModel { elapsedTask = nil let modules = await device.modules - let chip = MWSensorFusionChip(accImpl: modules[.accelerometer]?.implementation ?? 1) ?? .bmi160 + let impls = SensorImplementations(modules: modules) // Per-record try/catch — a single failed `stopOne` (BLE hiccup, // unexpected board state) used to abort the loop, leaving the // remaining records' `status` stuck at `.running` and the global @@ -158,7 +177,7 @@ final class LogSessionViewModel { // freed AND resurrect the record as pending everywhere. for record in activeRecords where record.status != .downloaded { do { - try await stopOne(record: record, chip: chip) + try await stopOne(record: record, impls: impls) } catch { lastError = AppError(error: error) } @@ -168,7 +187,7 @@ final class LogSessionViewModel { phase = .stopped } - private func stopOne(record: LogSessionRecord, chip: MWSensorFusionChip) async throws { + private func stopOne(record: LogSessionRecord, impls: SensorImplementations) async throws { guard let selection = Self.decode(record.configJSON, kind: record.sensorKind) else { return } switch selection.id { case .temperature: @@ -186,7 +205,7 @@ final class LogSessionViewModel { ) try await device.stopLogging(polled, handles: handles) default: - if let loggable = Self.makeLoggable(for: selection, chip: chip) { + if let loggable = Self.makeLoggable(for: selection, impls: impls) { try await device.stopLogging(loggable) } } @@ -194,13 +213,13 @@ final class LogSessionViewModel { private func rollbackStartedRecords( _ records: [LogSessionRecord], - chip: MWSensorFusionChip, + impls: SensorImplementations, context: ModelContext ) async -> [LogSessionRecord] { var stillRunning: [LogSessionRecord] = [] for record in records { do { - try await stopOne(record: record, chip: chip) + try await stopOne(record: record, impls: impls) context.delete(record) } catch { record.status = .running @@ -266,12 +285,11 @@ final class LogSessionViewModel { /// natively loggable on the board (baro / temp / humidity / ambient /// light) — `SensorPickerSection` should already keep those out of the /// logging Add menu, but the nil branch is the safety net. - static func makeLoggable(for selection: SensorSelection, chip: MWSensorFusionChip) -> (any MWLoggable)? { + static func makeLoggable(for selection: SensorSelection, impls: SensorImplementations) -> (any MWLoggable)? { switch selection.id { case .accelerometer: let rangeG = Float(selection.range ?? 2) - let impl: UInt8 = chip == .bmi270 ? 4 : 1 - switch MWAccelerometer.make(impl: impl, odrHz: selection.hz, rangeG: rangeG) { + switch MWAccelerometer.make(impl: impls.accel, odrHz: selection.hz, rangeG: rangeG) { case .bmi160(let s)?: return s case .bmi270(let s)?: return s case nil: return nil @@ -279,8 +297,7 @@ final class LogSessionViewModel { case .gyroscope: let rangeDPS = Float(selection.range ?? 2000) - let impl: UInt8 = chip == .bmi270 ? 1 : 0 - switch MWGyroscope.make(impl: impl, odrHz: selection.hz, rangeDPS: rangeDPS) { + switch MWGyroscope.make(impl: impls.gyro, odrHz: selection.hz, rangeDPS: rangeDPS) { case .bmi160(let s)?: return s case .bmi270(let s)?: return s case nil: return nil @@ -297,13 +314,13 @@ final class LogSessionViewModel { case .sensorFusion(let out): switch out { - case .quaternion: return MWSensorFusionQuaternion(chip: chip) - case .eulerAngles: return MWSensorFusionEuler(chip: chip) - case .gravity: return MWSensorFusionGravity(chip: chip) - case .linearAcceleration: return MWSensorFusionLinearAcceleration(chip: chip) - case .correctedAcceleration: return MWSensorFusionCorrectedAcc(chip: chip) - case .correctedAngularVelocity: return MWSensorFusionCorrectedGyro(chip: chip) - case .correctedMagneticField: return MWSensorFusionCorrectedMag(chip: chip) + case .quaternion: return MWSensorFusionQuaternion(chip: impls.fusionChip) + case .eulerAngles: return MWSensorFusionEuler(chip: impls.fusionChip) + case .gravity: return MWSensorFusionGravity(chip: impls.fusionChip) + case .linearAcceleration: return MWSensorFusionLinearAcceleration(chip: impls.fusionChip) + case .correctedAcceleration: return MWSensorFusionCorrectedAcc(chip: impls.fusionChip) + case .correctedAngularVelocity: return MWSensorFusionCorrectedGyro(chip: impls.fusionChip) + case .correctedMagneticField: return MWSensorFusionCorrectedMag(chip: impls.fusionChip) } case .barometer: diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index dccd02e..958c144 100644 --- a/Sources/MetaWear/MetaWearDevice.swift +++ b/Sources/MetaWear/MetaWearDevice.swift @@ -575,6 +575,7 @@ public actor MetaWearDevice { chunks.append((id: response[2], byteCount: Int(chunk.length))) } loggerRegistry[loggable.loggerKey] = chunks + mwLog("[Device] startLogging \(loggable.module.name): assigned logger ids \(chunks.map(\.id))") // Enable sensor output and start hardware for cmd in loggable.enableCommands where !cmd.isEmpty { try await proto.write(cmd) } @@ -797,6 +798,7 @@ public actor MetaWearDevice { (id: $0.loggerID, byteCount: Int($1.length)) } loggerRegistry[logger.loggerKey] = chunks + mwLog("[Device] recoverLoggers \(logger.loggerKey): matched logger ids \(chunks.map(\.id))") } /// Download raw log entries from the device. @@ -1137,7 +1139,42 @@ public actor MetaWearDevice { try await proto.write(MWPacket.command(.logging, 0x09, [0xFF, 0xFF, 0xFF, 0xFF])) if let pageStream { - let completed = await Self.awaitFirstElement(of: pageStream, timeout: .seconds(30)) + // Two independent completion signals, first one wins: + // 1. the documented 0x0D notification — which field testing + // shows some boards never send after a drop, and + // 2. LOG_LENGTH reading 0 on two consecutive polls — the + // entries provably gone. + // Bounded at 60 s: one board's drop outlived the previous 30 s + // bound (dirty NAND, long GC) and arming loggers on the still- + // grinding board recorded almost nothing. + let proto = self.proto + let completed = await withTaskGroup(of: Bool.self) { group in + group.addTask { + await Self.awaitFirstElement(of: pageStream, timeout: .seconds(60)) + } + group.addTask { + var zeroReads = 0 + for _ in 0..<30 { + try? await Task.sleep(for: .seconds(2)) + guard let response = try? await proto.read(.logging, 0x05), + response.count >= 6 else { continue } + if MWPacketParser.parseUInt32LE(response, offset: 2) == 0 { + zeroReads += 1 + if zeroReads >= 2 { return true } + } else { + zeroReads = 0 + } + } + return false + } + var success = false + for await finished in group where finished { + success = true + break + } + group.cancelAll() + return success + } mwLog(completed ? "[Device] clearLog: drop completed" : "[Device] clearLog: drop completion TIMED OUT — proceeding") From 06dbf1aa5a61e1afcd6733d0c4dc7fd9fd1edb64 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:19:12 -0700 Subject: [PATCH 12/17] Saved sessions are one tap from the group screen; empty records close out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth field test: the pipeline END-TO-END worked for accel on both boards (3397 + 3831 samples saved, group-tagged, clear handshake "drop completed", all four logger ids assigned) — and exposed two UX truths plus kept the one hardware mystery alive: - "Where are the files?" The group screen never said. Saved sessions live in Session History; the Last Run section now links straight to it whenever any board saved. - Boards kept showing "logging" in the scan list after a complete collect: records that decoded EMPTY were kept as pending "for retry" — but a COMPLETED drain has page-confirmed (nulled) every entry, so those records can never be fulfilled. They now close as .failed, releasing the pending badges, the eternal "Ready To Collect", and the Logging screen. - Start passes log the per-module implementation bytes so the next run pins the remaining mystery: gyro loggers armed with valid ids (2,3) on the correct data registers wrote ZERO entries on both boards, while byte-identical sequences logged gyro fine in an earlier run. Accel is untouched by whatever this is. Co-Authored-By: Claude Fable 5 --- .../Features/Logging/GroupLoggingView.swift | 14 ++++++++++++++ .../MetaWear/ViewModels/DownloadViewModel.swift | 13 +++++++++---- .../MetaWear/ViewModels/LogSessionViewModel.swift | 3 +++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index b545977..14bc553 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -106,6 +106,20 @@ struct GroupLoggingView: View { if case .failed = $0.phase { return true } return false } + // The whole point of collecting: the saved sessions. Surface + // the way to them right where the results are shown — the + // files live in Session History (per-board sections, CSV + // share from each session). + let savedAny = coordinator.boards.contains { + if case .saved = $0.phase { return true } + if case .savedWithIssues = $0.phase { return true } + return false + } + if !coordinator.isBusy, savedAny { + NavigationLink(value: DeviceFeaturePane.sessionHistory) { + Label("View Saved Sessions", systemImage: "clock.arrow.circlepath") + } + } if !coordinator.isBusy, coordinator.lastPass == .collect, !failed.isEmpty { Button { let members = failed.map { diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 5cde696..3965938 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -126,13 +126,18 @@ final class DownloadViewModel { snapshots.append(snap) record.status = .downloaded } else { - Self.debugLog("\(record.sensorKind): decoded EMPTY — record kept") - record.status = .stopped + // The drain COMPLETED (every page confirmed = entries + // nulled on the board), so a record with nothing in it + // can never be fulfilled — closing it as .failed stops + // it haunting the pending list forever (stuck logging + // badges on scan rows, an eternal "Ready To Collect"). + Self.debugLog("\(record.sensorKind): decoded EMPTY — closing record") + record.status = .failed keptBoardData = true } } catch { Self.debugLog("\(record.sensorKind): decode/save FAILED — \(error)") - record.status = .stopped + record.status = .failed keptBoardData = true lastError = AppError(error: error) } @@ -142,7 +147,7 @@ final class DownloadViewModel { if keptBoardData { phase = .ready( snapshots: snapshots, - warning: "Some sensors' data could not be decoded; everything decodable was saved." + warning: "Some sensors recorded no data; everything else was saved." ) return } diff --git a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift index 62a1f92..59375c1 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift @@ -62,6 +62,9 @@ final class LogSessionViewModel { let context = containers.local.mainContext let modules = await device.modules let impls = SensorImplementations(modules: modules) + #if DEBUG + fputs("[LogSession] impls: accel \(impls.accel), gyro \(impls.gyro), fusion \(impls.fusionChip)\n", stderr) + #endif var records: [LogSessionRecord] = [] do { for selection in selections { From ef245e85cb477540e40f20904bc5c84741a33b9b Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:21:45 -0700 Subject: [PATCH 13/17] Settings shows 0/0 after a clear instead of re-reading a lying board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report: Clear Logs & Loggers didn't refresh the counts to zero. The action DID re-read the board — but an MMS reports a sentinel LOG_LENGTH for up to ~60 s post-clear while GC settles (the same quirk the orphan detection has documented for ages), so the refresh faithfully displayed stale numbers. clearLog now waits for the firmware's drop-completion handshake, so at that moment zero is the ground truth — display it directly and let the next natural screen visit re-read. Co-Authored-By: Claude Fable 5 --- .../MetaWear/Features/Settings/DeviceSettingsView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index 72cfd1b..0ddd140 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -157,7 +157,14 @@ struct DeviceSettingsView: View { try await device.clearLog() deleteLocalPendingRecords(for: device.identifier) appStore.refreshPendingLogSessions() - await refreshLogStats() + // Do NOT re-read the board here: clearLog just waited for the + // firmware's drop-completion handshake, so the truth IS zero — + // but the MMS reports a sentinel LOG_LENGTH (often 1) for up + // to ~60 s post-clear while GC settles, which made this screen + // look like the clear hadn't worked. The next screen visit + // re-reads naturally. + logEntryCount = 0 + activeLoggerCount = 0 } catch { clearLogError = AppError(error: error) } From ad919fa615163d84849816d93b5c70c6e8c2efef Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:23:24 -0700 Subject: [PATCH 14/17] Disconnect returns to the scan list, not a blank detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old field bug: tapping X on the device screen (compact width) landed on an empty page with only a Back button instead of the scan list. The disconnect flow was correct — activeDeviceID nils and the onChange fires — but it reset the navigation path AND flipped the compact column in the SAME transaction, and NavigationSplitView swallows the column change while processing the path reset + detail-content swap. Two-part fix: the sidebar flip now lands on its own main-actor tick, and the blank pane carries an onAppear safety net that pushes focus back to the sidebar if it ever becomes the visible compact column (no-op in regular width, where preferredCompactColumn has no effect). Co-Authored-By: Claude Fable 5 --- Apps/MetaWear/MetaWear/App/RootView.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Apps/MetaWear/MetaWear/App/RootView.swift b/Apps/MetaWear/MetaWear/App/RootView.swift index 79464ac..a43790b 100644 --- a/Apps/MetaWear/MetaWear/App/RootView.swift +++ b/Apps/MetaWear/MetaWear/App/RootView.swift @@ -37,7 +37,13 @@ struct RootView: View { // transition. In regular (iPad) the sidebar is // already showing alongside, so the detail just // sits empty rather than nagging the user. + // Safety net: if this pane ever DOES become the + // visible column in compact (the onChange's flip + // can be swallowed mid-transition), push focus + // back to the sidebar. Harmless in regular width + // — preferredCompactColumn only affects compact. Color.clear + .onAppear { preferredColumn = .sidebar } } } .navigationDestination(for: DeviceFeaturePane.self) { pane in @@ -62,7 +68,18 @@ struct RootView: View { // (e.g. a foreignDownload pushed for the previous board) must // not resolve against the next one. path = NavigationPath() - preferredColumn = newID == nil ? .sidebar : .detail + if newID == nil { + // Deferred one tick: flipping the compact column in the + // SAME transaction as the path reset + the detail content + // swapping to the blank pane gets swallowed by + // NavigationSplitView — compact users ended up stranded on + // an empty detail page with only a Back button (field bug: + // tap X to disconnect → blank screen instead of the scan + // list). + Task { @MainActor in preferredColumn = .sidebar } + } else { + preferredColumn = .detail + } } .overlay { if isConnecting { From 4fd171c928c6521010517affa767a1eea5999304 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:31:06 -0700 Subject: [PATCH 15/17] Group start verifies entries are LANDING before trusting a board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh field test unmasked the last accomplice: the LENGTH==0-twice clear-completion proxy. A board whose previous session was fully drained READS zero instantly — but its page-confirm-nulled flash still needs garbage collection, which the very drop we issue kicks off. The proxy declared the clear complete in two fast polls, the loggers armed onto the grinding flash, and BOTH boards logged zero entries — accel only, no gyro involved. All seven field sessions now reduce to one rule: a session started during NAND GC records nothing, and no firmware signal reliably marks GC's end. So the start pass stops trusting proxies and verifies the only signal that matters: the entry count RISING. While logging is enabled, LOG_LENGTH includes the RAM page, so a healthy board confirms on the first 2 s poll; a GC-bound board is simply waited out (90 s bound, "Confirming data is recording…" in its row). If a board never confirms, its doomed session is torn down — sensors stopped, records deleted, no zombies — and the row says plainly to wait a minute and try again, instead of pretending a dead session succeeded. E2E group tests exercise the gate against the demo fleet (their timings grew by exactly the verification polls). Co-Authored-By: Claude Fable 5 --- .../Features/Logging/GroupLoggingView.swift | 3 +- .../ViewModels/GroupCaptureCoordinator.swift | 49 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 14bc553..2f8464e 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -404,7 +404,7 @@ struct GroupLoggingView: View { switch phase { case .pending: Image(systemName: "circle.dotted").foregroundStyle(.secondary) - case .connecting, .starting, .stopping, .downloading: + case .connecting, .starting, .verifying, .stopping, .downloading: ProgressView().controlSize(.small) case .logging: Image(systemName: "record.circle.fill").foregroundStyle(Palette.danger) @@ -425,6 +425,7 @@ struct GroupLoggingView: View { case .pending: return "Waiting…" case .connecting: return "Connecting…" case .starting: return "Starting loggers…" + case .verifying: return "Confirming data is recording… (up to a minute after a clear)" case .logging: return "Logging" case .stopping: return "Stopping…" case .downloading: return "Downloading…" diff --git a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift index 69f0e96..66b8c7e 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import SwiftData import MetaWear import MetaWearPersistence @@ -29,6 +30,8 @@ final class GroupCaptureCoordinator { case pending case connecting case starting + /// Loggers armed; waiting for proof that entries are landing. + case verifying /// Start pass succeeded — the board is recording on its own. case logging case stopping @@ -147,11 +150,34 @@ final class GroupCaptureCoordinator { let vm = LogSessionViewModel(device: member.device, containers: containers) await vm.start(selections, groupID: groupID) if case .running = vm.phase { - // Best-effort: the heartbeat is a courtesy indicator — - // an LED hiccup must not fail a successfully started - // board. - try? await member.device.setLED(red: Self.recordingHeartbeat) - setPhase(id, .logging) + // Don't take the firmware's word for it — verify that + // entries are actually landing before leaving the + // board. NAND garbage collection (kicked off by any + // drop, including our clear moments ago) silently + // swallows samples while it grinds, and SEVEN field + // sessions proved no proxy detects its end: LOG_LENGTH + // reads 0 on an empty-but-dirty log, and the 0x0D + // drop notification may never arrive. The entry count + // rising is the only trustworthy signal — while + // logging is enabled it includes the RAM page, so a + // healthy board confirms within seconds. + setPhase(id, .verifying) + if await confirmEntriesLanding(on: member.device) { + // Best-effort: the heartbeat is a courtesy + // indicator — an LED hiccup must not fail a + // successfully started board. + try? await member.device.setLED(red: Self.recordingHeartbeat) + setPhase(id, .logging) + } else { + // The session is doomed — the sensors run but the + // flash swallows everything. Tear it down so no + // zombie records linger. + await vm.stop() + let context = containers.local.mainContext + vm.activeRecords.forEach { context.delete($0) } + try? context.save() + setPhase(id, .failed("The board's flash is still busy (housekeeping after a clear). Wait a minute and start again.")) + } } else { setPhase(id, .failed(vm.lastError?.message ?? "Logging did not start")) } @@ -320,6 +346,19 @@ final class GroupCaptureCoordinator { return true } + /// Poll the live entry count until it rises — proof the board is + /// genuinely recording. 90 s bound covers the longest post-clear GC + /// observed in the field; healthy boards confirm on the first poll. + private func confirmEntriesLanding(on device: MetaWearDevice) async -> Bool { + for _ in 0..<45 { + try? await Task.sleep(for: .seconds(2)) + if let count = try? await device.read(MWLogLength()).value, count > 0 { + return true + } + } + return false + } + private struct ConnectTimeoutError: Error {} private struct BoardBusyError: Error {} From 5ddbec58b0590cac5325dca9c4155b85af7676f0 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:39:31 -0700 Subject: [PATCH 16/17] Group Logging becomes a navigation value; View Saved Sessions works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighth field test: COMPLETE END-TO-END SUCCESS on the mixed fleet — both boards, both sensors, balanced per-logger entry counts, sessions saved and attributed, boards cleaned after collect. The verification gate confirmed entries landing before each board was released, closing the "gyro logs nothing" mystery as one more face of the GC law. The one remaining break was navigational: "View Saved Sessions" on the group screen silently did nothing. Cause: GroupLoggingView was presented via a navigationDestination(isPresented:) binding — a screen the NavigationPath doesn't contain — and SwiftUI drops value links tapped inside such a screen. The console also flagged duplicate MWSessionSnapshot destinations (SessionHistoryView declared its own, so two history instances in one stack collided). - Group Logging is now DeviceFeaturePane.groupLogging, pushed by value like every other pane; the toolbar Button becomes a NavigationLink. Value links inside it resolve normally. - The session-detail destination is declared ONCE at each stack root (sidebar + detail) instead of inside SessionHistoryView. - The scan handoff no longer needs ScanView to know about the group screen: ScanView stops the shared scan unconditionally on disappear, and GroupLoggingView re-asserts it after the push transition settles. Co-Authored-By: Claude Fable 5 --- Apps/MetaWear/MetaWear/App/RootView.swift | 14 +++++++++++++ .../Features/Logging/GroupLoggingView.swift | 5 +++++ .../MetaWear/Features/Scan/ScanView.swift | 21 +++++++------------ .../Sessions/SessionHistoryView.swift | 1 - 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/Apps/MetaWear/MetaWear/App/RootView.swift b/Apps/MetaWear/MetaWear/App/RootView.swift index a43790b..51e0315 100644 --- a/Apps/MetaWear/MetaWear/App/RootView.swift +++ b/Apps/MetaWear/MetaWear/App/RootView.swift @@ -1,5 +1,6 @@ import SwiftUI import MetaWear +import MetaWearPersistence struct RootView: View { @Environment(AppStore.self) private var appStore @@ -22,6 +23,13 @@ struct RootView: View { .navigationDestination(for: DeviceFeaturePane.self) { pane in pane.destination() } + // Declared ONCE at the stack root: SessionHistoryView can + // appear more than once per stack (scan-root link + the + // group screen's link), and per-instance declarations + // triggered SwiftUI's duplicate-destination warning. + .navigationDestination(for: MWSessionSnapshot.self) { + SessionDetailView(snapshot: $0) + } } } detail: { NavigationStack(path: $path) { @@ -49,6 +57,9 @@ struct RootView: View { .navigationDestination(for: DeviceFeaturePane.self) { pane in pane.destination() } + .navigationDestination(for: MWSessionSnapshot.self) { + SessionDetailView(snapshot: $0) + } } } .background { @@ -176,6 +187,8 @@ enum DeviceFeaturePane: Hashable { case sensorConfig case liveStream([SensorSelection]) case logSession + /// MetaBase-style multi-board logging — lives on the sidebar stack. + case groupLogging case download /// The Download screen in foreign-session mode: drains a board whose /// logging was started on another device via the anonymous-logger path. @@ -191,6 +204,7 @@ enum DeviceFeaturePane: Hashable { case .sensorConfig: SensorConfigView() case .liveStream(let sels): LiveStreamView(selections: sels) case .logSession: LogSessionView() + case .groupLogging: GroupLoggingView() case .download: DownloadView() case .foreignDownload(let s): DownloadView(foreign: s) case .sessionHistory: SessionHistoryView() diff --git a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift index 2f8464e..4010bcd 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -53,6 +53,11 @@ struct GroupLoggingView: View { if scanVM == nil { scanVM = ScannerViewModel(scanner: appStore.scanner) } scanVM?.startScan() appStore.refreshPendingLogSessions() + // ScanView stops the SHARED scan in its onDisappear, which + // fires AFTER this task during the push transition — re-assert + // once the transition settles so nearby candidates stay live. + try? await Task.sleep(for: .milliseconds(800)) + scanVM?.startScan() } // Centered alert, not a confirmation dialog — dialogs anchor as // popovers in regular width and "pop up anywhere on the page" diff --git a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift index 8645bb5..71e3f2b 100644 --- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift +++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift @@ -11,7 +11,6 @@ struct ScanView: View { /// detail column never re-appears in compact width. let showDetail: () -> Void @State private var viewModel: ScannerViewModel? - @State private var showGroupLogging = false private var pinnedID: UUID? { appStore.rememberedDevices.first?.peripheralUUID @@ -178,30 +177,24 @@ struct ScanView: View { 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. - Button { - showGroupLogging = true - } label: { + // 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) } } - .navigationDestination(isPresented: $showGroupLogging) { - GroupLoggingView() - } .task { if viewModel == nil { viewModel = ScannerViewModel(scanner: appStore.scanner) } viewModel?.startScan() } - .onDisappear { - // Group Logging (pushed from here) needs the shared scan alive - // for its nearby candidates — both screens drive the SAME - // MetaWearScanner, so stopping on push would freeze freshness. - if !showGroupLogging { viewModel?.stopScan() } - } + .onDisappear { viewModel?.stopScan() } } /// True while any pending session carries a group tag — a fleet is diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift index 4cef059..6263998 100644 --- a/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift +++ b/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift @@ -31,7 +31,6 @@ struct SessionHistoryView: View { } } .navigationTitle("Session History") - .navigationDestination(for: MWSessionSnapshot.self) { SessionDetailView(snapshot: $0) } .overlay { if snapshots.isEmpty { ContentUnavailableView("No sessions yet", systemImage: "clock", description: Text("Downloaded log sessions will appear here.")) From 33281ede4b18f3a01f3bf182132eed511a8100e1 Mon Sep 17 00:00:00 2001 From: lkasso Date: Sun, 19 Jul 2026 20:42:18 -0700 Subject: [PATCH 17/17] History section titles show the MAC, not the DIS serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner request: "MetaWear · 0648AF" → "MetaWear · ". The MAC is the board identity users see everywhere else in the app (device page, group picker, rename gate), while the DIS serial appears nowhere else. Sessions store the serial, remembered-device records sync both fields — so the history screen translates serial → MAC through Remembered. Grouping still KEYS on the serial (stamped on every record; MAC knowledge is not guaranteed), and untranslatable boards keep the serial fallback. Unit-tested. Co-Authored-By: Claude Fable 5 --- .../Sessions/SessionHistoryView.swift | 32 ++++++++++++++++--- .../SessionHistoryGroupingTests.swift | 18 +++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift index 6263998..f2f774a 100644 --- a/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift +++ b/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift @@ -11,7 +11,7 @@ struct SessionHistoryView: View { // Sessions grouped by the board that captured them — with several // boards logging, a flat list can't attribute rows. List { - ForEach(SessionHistoryGrouping.sections(from: snapshots)) { section in + ForEach(SessionHistoryGrouping.sections(from: snapshots, macBySerial: macBySerial)) { section in Section(section.title) { ForEach(section.sessions, id: \.id) { snap in NavigationLink(value: snap) { @@ -44,6 +44,21 @@ struct SessionHistoryView: View { } } + /// Serial → MAC translation from the remembered-device records (which + /// sync both fields). The MAC is the board's identity everywhere else + /// in the app, so section titles prefer it over the DIS serial. + private var macBySerial: [String: String] { + Dictionary( + appStore.rememberedDevices.compactMap { device in + guard let serial = device.serialNumber, let mac = device.macAddress else { + return nil + } + return (serial, mac) + }, + uniquingKeysWith: { first, _ in first } + ) + } + private func reload() async { do { snapshots = try await appStore.persistence.fetchAllSessions() @@ -72,7 +87,13 @@ enum SessionHistoryGrouping { let sessions: [MWSessionSnapshot] } - static func sections(from snapshots: [MWSessionSnapshot]) -> [BoardSection] { + /// - Parameter macBySerial: serial → MAC translation; when a board's + /// MAC is known, titles show it instead of the DIS serial ("MetaWear + /// · CD:2E:…" beats "MetaWear · 0648AF" — the MAC is the identity + /// users see everywhere else). Grouping still KEYS on the serial: + /// it's stamped on every record, MAC knowledge is not. + static func sections(from snapshots: [MWSessionSnapshot], + macBySerial: [String: String] = [:]) -> [BoardSection] { let grouped = Dictionary(grouping: snapshots) { snap in snap.deviceSerial.isEmpty ? snap.deviceID.uuidString : snap.deviceSerial } @@ -93,17 +114,18 @@ enum SessionHistoryGrouping { } .map { key, sessions in let serial = sessions.first?.deviceSerial ?? "" + let displayID = macBySerial[serial] ?? serial // Sessions arrive newest-first from the store, so `first` // is the most recently stamped name — a rename wins. guard let name = sessions.compactMap(\.deviceName).first(where: { !$0.isEmpty }) else { return BoardSection( id: key, - title: serial.isEmpty ? "Unknown board" : serial, + title: displayID.isEmpty ? "Unknown board" : displayID, sessions: sessions ) } - let title = sharedNames.contains(name) && !serial.isEmpty - ? "\(name) · \(serial)" + let title = sharedNames.contains(name) && !displayID.isEmpty + ? "\(name) · \(displayID)" : name return BoardSection(id: key, title: title, sessions: sessions) } diff --git a/Apps/MetaWear/MetaWearTests/SessionHistoryGroupingTests.swift b/Apps/MetaWear/MetaWearTests/SessionHistoryGroupingTests.swift index 1749551..d9ff6da 100644 --- a/Apps/MetaWear/MetaWearTests/SessionHistoryGroupingTests.swift +++ b/Apps/MetaWear/MetaWearTests/SessionHistoryGroupingTests.swift @@ -77,6 +77,24 @@ struct SessionHistoryGroupingTests { #expect(sections[1].id == id.uuidString) } + /// Titles prefer the MAC (the board identity users see everywhere + /// else) over the DIS serial when the translation is known — grouping + /// still keys on the serial, which every record carries. + @Test func titlesPreferMACWhenKnown() { + let sections = SessionHistoryGrouping.sections( + from: [ + snap(serial: "0123FF", name: "MetaWear", start: 200), + snap(serial: "045A2C", name: "MetaWear", start: 100), + ], + macBySerial: ["0123FF": "CD:2E:12:34:56:78"] + ) + #expect(sections[0].title == "MetaWear · CD:2E:12:34:56:78") + // Untranslated boards keep the serial fallback. + #expect(sections[1].title == "MetaWear · 045A2C") + // Grouping keys stay serial-based regardless. + #expect(sections[0].id == "0123FF") + } + @Test func sectionsOrderNewestFirst() { let sections = SessionHistoryGrouping.sections(from: [ snap(serial: "AAAA01", name: "old board", start: 100),