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/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/App/RootView.swift b/Apps/MetaWear/MetaWear/App/RootView.swift index 79464ac..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) { @@ -37,12 +45,21 @@ 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 pane.destination() } + .navigationDestination(for: MWSessionSnapshot.self) { + SessionDetailView(snapshot: $0) + } } } .background { @@ -62,7 +79,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 { @@ -159,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. @@ -174,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 new file mode 100644 index 0000000..4010bcd --- /dev/null +++ b/Apps/MetaWear/MetaWear/Features/Logging/GroupLoggingView.swift @@ -0,0 +1,455 @@ +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) + } else if !coordinator.isBusy { + boardPickerSection(now: timeline.date) + SensorPickerSection( + selections: $selections, + availableModules: Set(MWModule.allCases), + availableTempChannels: [], + supportedKinds: Self.groupLoggableKinds, + isLocked: false + ) + startSection(now: timeline.date) + } + } + } + .navigationTitle("Group Logging") + .task { + 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" + // (owner feedback); the app's convention is centered alerts for + // every confirm. + .alert( + "Stop logging on all boards and download their data?", + 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.") + } + } + + // 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) + } + } + } + // 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 + } + // 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 { + 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: { + 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) + 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) + .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) + } + } + Spacer() + if let rssi = candidate.rssi { + 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: { + 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) -> some View { + let members = selectedMembers(now: now) + return Section { + Button { + Task { + await appStore.groupCapture.startAll(members: members, selections: selections) + } + } label: { + 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.") + } + } + + // MARK: - Candidates & members + + 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 + /// 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) + 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( + 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, + 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, + rssi: nil, + detail: DemoMode.macAddress(for: demo.identifier) ?? demo.identifier.uuidString + )) + } + } + 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, .verifying, .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 .savedWithIssues: + Image(systemName: "checkmark.circle.trianglebadge.exclamationmark") + .foregroundStyle(Palette.warning) + 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 .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…" + 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 + } + } + + /// 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..71e3f2b 100644 --- a/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift +++ b/Apps/MetaWear/MetaWear/Features/Scan/ScanView.swift @@ -174,6 +174,19 @@ 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. VALUE-based + // push, deliberately: a screen presented via an + // isPresented destination can't resolve value links tapped + // inside it (the path doesn't contain the screen), which + // silently broke "View Saved Sessions" on the group page. + NavigationLink(value: DeviceFeaturePane.groupLogging) { + Label("Group Logging", systemImage: "square.stack.3d.down.right") + } + .tint(hasActiveGroup ? Palette.danger : nil) + } } .task { if viewModel == nil { @@ -184,6 +197,12 @@ struct ScanView: View { .onDisappear { 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 { if appStore.activeDeviceID == uuid, appStore.connectionState != .disconnected, diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionHistoryView.swift index 4cef059..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) { @@ -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.")) @@ -45,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() @@ -73,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 } @@ -94,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/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) } diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 0da4e5c..3965938 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 @@ -61,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 @@ -82,14 +91,20 @@ 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 // stream together; per-record dispatch happens after. let allEntries: [RawLogEntry] do { - allEntries = try await drainRawDownload() + 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) @@ -103,18 +118,26 @@ final class DownloadViewModel { do { if let snap = try await decodeAndSave( record: record, - chip: chip, + impls: impls, info: info, entries: allEntries ) { + Self.debugLog("\(record.sensorKind): saved \(snap.sampleCount) samples") snapshots.append(snap) record.status = .downloaded } else { - 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 { - record.status = .stopped + Self.debugLog("\(record.sensorKind): decode/save FAILED — \(error)") + record.status = .failed keptBoardData = true lastError = AppError(error: error) } @@ -124,7 +147,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 recorded no data; everything else was saved." ) return } @@ -187,7 +210,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") } @@ -195,6 +220,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) } @@ -227,8 +253,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 { @@ -362,7 +391,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 { @@ -379,7 +408,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) } } @@ -387,7 +416,7 @@ final class DownloadViewModel { private func decodeAndSave( record: LogSessionRecord, - chip: MWSensorFusionChip, + impls: SensorImplementations, info: MWDeviceInformation, entries: [RawLogEntry] ) async throws -> MWSessionSnapshot? { @@ -400,8 +429,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 @@ -409,8 +437,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 @@ -426,25 +453,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/GroupCaptureCoordinator.swift b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift new file mode 100644 index 0000000..66b8c7e --- /dev/null +++ b/Apps/MetaWear/MetaWear/ViewModels/GroupCaptureCoordinator.swift @@ -0,0 +1,379 @@ +import Foundation +import Observation +import SwiftData +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 + /// 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 + 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. + case skipped(String) + case failed(String) + + var isTerminal: Bool { + switch self { + case .logging, .saved, .savedWithIssues, .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 + + 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 — + /// 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 + + /// 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 + /// 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. + func startAll(members: [Member], selections: [SensorSelection]) async { + guard !isBusy, !members.isEmpty, !selections.isEmpty else { return } + isBusy = true + lastPass = .start + defer { isBusy = false } + + let groupID = 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) + // 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 { + // 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")) + } + 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 + lastPass = .collect + 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 + // 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 } + + 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 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)) + } + 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 + } + + /// 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 {} + + 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..59375c1 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 @@ -33,17 +52,23 @@ 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 } let context = containers.local.mainContext let modules = await device.modules - let chip = MWSensorFusionChip(accImpl: modules[.accelerometer]?.implementation ?? 1) ?? .bmi160 + 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 { - if let record = try await startOne(selection: selection, chip: chip, context: context) { + if let record = try await startOne(selection: selection, impls: impls, context: context, groupID: groupID) { records.append(record) } } @@ -52,7 +77,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 @@ -75,8 +100,9 @@ final class LogSessionViewModel { /// way. Returns nil if the sensor can't be logged on this board. private func startOne( selection: SensorSelection, - chip: MWSensorFusionChip, - context: ModelContext + impls: SensorImplementations, + context: ModelContext, + groupID: UUID? = nil ) async throws -> LogSessionRecord? { switch selection.id { case .temperature: @@ -92,7 +118,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,20 +136,22 @@ 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 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, sensorKind: selection.id.persistenceKey, configJSON: Self.encode(selection), loggerKey: loggable.loggerKey, - status: .running + status: .running, + groupID: groupID ) context.insert(record) return record @@ -134,7 +163,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 @@ -144,9 +173,14 @@ 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) + try await stopOne(record: record, impls: impls) } catch { lastError = AppError(error: error) } @@ -156,7 +190,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: @@ -174,7 +208,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) } } @@ -182,13 +216,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 @@ -254,12 +288,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 @@ -267,8 +300,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 @@ -285,13 +317,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/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift b/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift new file mode 100644 index 0000000..50b4538 --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/GroupCaptureCoordinatorTests.swift @@ -0,0 +1,158 @@ +import Foundation +import SwiftData +import Testing +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 +/// 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) + } +} 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), diff --git a/Sources/MetaWear/MetaWearDevice.swift b/Sources/MetaWear/MetaWearDevice.swift index b014240..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) } @@ -607,6 +608,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 +747,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 } @@ -791,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. @@ -804,7 +812,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") @@ -816,7 +828,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 +840,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, expectEntries: expectEntries) // Empty log buffer: short-circuit. Issuing the readout with count=0 // produces no `0x07` raw entries, no `0x0D` page-completed notice, and @@ -1110,11 +1118,94 @@ 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 { + // 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") + 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. /// @@ -1150,6 +1241,54 @@ 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, expectEntries: 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() + 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() + mwLog("[Device] settledLogLength: read \(current)") + if current == previous, !(expectEntries && current == 0) { return current } + previous = current + } + } + // MARK: - Logger recovery /// Timeout for slot-enumeration probes (`queryActiveLoggers` / 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):