diff --git a/Apps/MetaWear/MetaWear/App/AnyChartSample.swift b/Apps/MetaWear/MetaWear/App/AnyChartSample.swift index e565133..458e3f7 100644 --- a/Apps/MetaWear/MetaWear/App/AnyChartSample.swift +++ b/Apps/MetaWear/MetaWear/App/AnyChartSample.swift @@ -75,7 +75,15 @@ extension AnyChartSample { AnyChartSample(time: s.date, f0: s.value.heading, f1: s.value.pitch, f2: s.value.roll, f3: s.value.yaw, channelCount: 4) } + static func from(_ s: MWLoggedSample) -> AnyChartSample { + AnyChartSample(time: s.date, f0: s.value.x, f1: s.value.y, f2: s.value.z, channelCount: 3) + } + static func from(_ s: MWLoggedSample) -> AnyChartSample { AnyChartSample(time: s.date, f0: s.value, channelCount: 1) } + + static func from(_ s: MWLoggedSample) -> AnyChartSample { + AnyChartSample(time: s.date, f0: s.value ? 1 : 0, channelCount: 1) + } } diff --git a/Apps/MetaWear/MetaWear/App/AppStore.swift b/Apps/MetaWear/MetaWear/App/AppStore.swift index 4d7a1f2..dc6c087 100644 --- a/Apps/MetaWear/MetaWear/App/AppStore.swift +++ b/Apps/MetaWear/MetaWear/App/AppStore.swift @@ -145,16 +145,35 @@ final class AppStore { // app's own UI handles teardown. if anyPendingForDevice { return } - let entryCount = (try? await device.read(MWLogLength()).value) ?? 0 + let entryCount: UInt32 + do { + entryCount = try await device.read(MWLogLength()).value + } catch { + lastError = AppError(error: error) + return + } guard entryCount > 0 else { return } - let activeLoggers = (try? await device.queryActiveLoggers()) ?? [] + let activeLoggers: [ActiveLogger] + do { + activeLoggers = try await device.queryActiveLoggers() + } catch { + // Enumeration failed, so we cannot prove the entries are + // undecodable garbage. Keep the on-board data and surface the + // orphan flow instead of clearing recoverable logs. + orphanLogState = OrphanLogState(entryCount: entryCount, deviceID: id) + return + } if activeLoggers.isEmpty { // Entries with no logger subscriptions are guaranteed // garbage — there's no decoder anywhere that could turn // them into samples. Drop them so we don't re-alert on // every reconnect. - try? await device.clearLog() + do { + try await device.clearLog() + } catch { + lastError = AppError(error: error) + } return } @@ -177,8 +196,13 @@ final class AppStore { orphanLogState = nil return } - try? await device.clearLog() - orphanLogState = nil + do { + try await device.clearLog() + orphanLogState = nil + } catch { + orphanLogState = state + lastError = AppError(error: error) + } } /// Dismiss the orphan-log alert without touching the board. Subsequent @@ -213,7 +237,7 @@ final class AppStore { // logger metadata — typically a corrupt slot or a logger // type we don't decode yet. Clear so we don't keep re- // alerting on every reconnect. - try? await device.clearLog() + try await device.clearLog() orphanDownloadPhase = .completed(savedCount: 0) return } @@ -241,7 +265,7 @@ final class AppStore { } } - try? await device.clearLog() + try await device.clearLog() orphanDownloadPhase = .completed(savedCount: savedCount) } catch { orphanDownloadPhase = .failed(message: error.localizedDescription) diff --git a/Apps/MetaWear/MetaWear/Export/CSVExporter.swift b/Apps/MetaWear/MetaWear/Export/CSVExporter.swift index a9c2273..f7088e4 100644 --- a/Apps/MetaWear/MetaWear/Export/CSVExporter.swift +++ b/Apps/MetaWear/MetaWear/Export/CSVExporter.swift @@ -67,6 +67,8 @@ enum CSVExporter { return try await store.exportTable(sessionID: snapshot.id, as: Quaternion.self) case EulerAngles.persistenceKind: return try await store.exportTable(sessionID: snapshot.id, as: EulerAngles.self) + case CorrectedCartesianFloat.persistenceKind: + return try await store.exportTable(sessionID: snapshot.id, as: CorrectedCartesianFloat.self) case Float.persistenceKind: return try await store.exportTable(sessionID: snapshot.id, as: Float.self) case Bool.persistenceKind: diff --git a/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift b/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift index 3b76469..53c6063 100644 --- a/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift +++ b/Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift @@ -15,13 +15,11 @@ nonisolated enum LiveBufferCSVExporter { /// Write one CSV per non-empty snapshot to a temp file. Safe to call off /// the main actor — the snapshots are value-typed and self-contained. /// Returns one `ExportSheetItem` per file actually written; empty - /// snapshots and failed writes are skipped silently. - static func write(snapshots: [ChannelSnapshot], deviceName: String) -> [ExportSheetItem] { + /// snapshots are skipped. + static func write(snapshots: [ChannelSnapshot], deviceName: String) throws -> [ExportSheetItem] { var items: [ExportSheetItem] = [] let now = Date.now - print("[Export] snapshots=\(snapshots.count) device=\(deviceName)") for snapshot in snapshots { - print("[Export] \(snapshot.key) samples=\(snapshot.samples.count)") guard !snapshot.samples.isEmpty else { continue } let csv = makeCSV(snapshot: snapshot) let filename = ExportFilename.make( @@ -32,20 +30,23 @@ nonisolated enum LiveBufferCSVExporter { let url = URL.temporaryDirectory.appending(path: filename) do { try csv.write(to: url, atomically: true, encoding: .utf8) - print("[Export] wrote \(url.lastPathComponent) (\(csv.utf8.count) bytes)") items.append(ExportSheetItem( url: url, subtitle: "\(snapshot.samples.count) samples · \(snapshot.displayName)" )) } catch { - print("[Export] FAILED \(url.path): \(error)") - continue + throw ExportWriteError(filename: filename, underlying: error) } } - print("[Export] done — \(items.count) file(s)") return items } + static func writeAsync(snapshots: [ChannelSnapshot], deviceName: String) async throws -> [ExportSheetItem] { + try await Task.detached(priority: .utility) { + try write(snapshots: snapshots, deviceName: deviceName) + }.value + } + private static func makeCSV(snapshot: ChannelSnapshot) -> String { let labels = snapshot.channelLabels let channelCount = labels.count @@ -95,4 +96,13 @@ nonisolated enum LiveBufferCSVExporter { private static func format(_ value: Float) -> String { value.formatted(.number.precision(.fractionLength(0...6))) } + + private struct ExportWriteError: LocalizedError { + let filename: String + let underlying: Error + + var errorDescription: String? { + "Could not write \(filename): \(underlying.localizedDescription)" + } + } } diff --git a/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift b/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift index 0175e9e..b6046cc 100644 --- a/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift @@ -16,8 +16,9 @@ struct ControlsView: View { var body: some View { Form { if let viewModel { + @Bindable var viewModel = viewModel Section("LED") { - Picker("Color", selection: Binding(get: { viewModel.ledColor }, set: { viewModel.ledColor = $0 })) { + Picker("Color", selection: $viewModel.ledColor) { Text("Green").tag(MWLED.Color.green) Text("Red").tag(MWLED.Color.red) Text("Blue").tag(MWLED.Color.blue) @@ -51,33 +52,31 @@ struct ControlsView: View { QuickReadRow( title: "Temperature", icon: "thermometer.medium", - value: viewModel.temperatureC.map { String(format: "%.1f °C", $0) }, + value: viewModel.temperatureC.map { Self.formattedMeasurement($0, unit: "°C") }, isLoading: viewModel.isReadingTemperature ) { Task { await viewModel.readTemperature() } } QuickReadRow( title: "Pressure", icon: "barometer", - value: viewModel.pressurePa.map { String(format: "%.1f hPa", $0 / 100) }, + value: viewModel.pressurePa.map { Self.formattedMeasurement($0 / 100, unit: "hPa") }, isLoading: viewModel.isReadingPressure ) { Task { await viewModel.readPressure() } } QuickReadRow( title: "Ambient Light", icon: "sun.max", - value: viewModel.ambientLightLux.map { String(format: "%.1f lux", $0) }, + value: viewModel.ambientLightLux.map { Self.formattedMeasurement($0, unit: "lux") }, isLoading: viewModel.isReadingLight ) { Task { await viewModel.readAmbientLight() } } } Section("Haptic") { - Stepper("Duty cycle: \(viewModel.motorDuty)%", - value: Binding(get: { Int(viewModel.motorDuty) }, - set: { viewModel.motorDuty = UInt8(clamping: $0) }), + Stepper("Duty cycle: \(viewModel.motorDutyPercent)%", + value: $viewModel.motorDutyPercent, in: 0...100, step: 10) - Stepper("Pulse width: \(viewModel.motorPulseMs) ms", - value: Binding(get: { Int(viewModel.motorPulseMs) }, - set: { viewModel.motorPulseMs = UInt16(clamping: $0) }), + Stepper("Pulse width: \(viewModel.motorPulseMilliseconds) ms", + value: $viewModel.motorPulseMilliseconds, in: 50...2000, step: 50) HStack { Button { Task { await viewModel.pulseMotor() } } label: { @@ -102,6 +101,18 @@ struct ControlsView: View { viewModel = ControlsViewModel(device: device) } } + .alert(item: Binding( + get: { viewModel?.lastError }, + set: { viewModel?.lastError = $0 } + )) { err in + Alert(title: Text("Control failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } + } + + private static func formattedMeasurement(_ value: Float, unit: String) -> String { + value.formatted(.number.precision(.fractionLength(1))) + " " + unit } } diff --git a/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift b/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift index 77ead89..79edc28 100644 --- a/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift +++ b/Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift @@ -10,6 +10,7 @@ struct LiveStreamView: View { /// is bound to the result that was just produced — avoids the stale-state /// timing where `.sheet(isPresented:)` captures an old `exportItems`. @State private var exportResult: ExportResult? + @State private var exportError: AppError? @State private var isExporting = false private var isCompact: Bool { horizontalSizeClass == .compact } @@ -79,6 +80,19 @@ struct LiveStreamView: View { .sheet(item: $exportResult) { result in ExportSheet(items: result.items) } + .alert(item: Binding( + get: { viewModel?.lastError }, + set: { viewModel?.lastError = $0 } + )) { err in + Alert(title: Text("Live stream failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } + .alert(item: $exportError) { err in + Alert(title: Text("Export failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } .task { guard let device = appStore.activeDevice else { return } if viewModel == nil { @@ -122,17 +136,15 @@ struct LiveStreamView: View { ) } isExporting = true - Task.detached { - let items = LiveBufferCSVExporter.write(snapshots: snapshots, deviceName: deviceName) - await MainActor.run { + Task { + do { + let items = try await LiveBufferCSVExporter.writeAsync(snapshots: snapshots, deviceName: deviceName) isExporting = false exportResult = ExportResult(items: items) + } catch { + isExporting = false + exportError = AppError(error: error) } } } } - -struct ExportResult: Identifiable { - let id = UUID() - let items: [ExportSheetItem] -} diff --git a/Apps/MetaWear/MetaWear/Features/Logging/DownloadView.swift b/Apps/MetaWear/MetaWear/Features/Logging/DownloadView.swift index 50fbaa8..3325ad2 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/DownloadView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/DownloadView.swift @@ -10,6 +10,8 @@ struct DownloadView: View { /// finished snapshots can also offer a ShareLink without a second tap /// or a follow-up sheet. @State private var exportItems: [UUID: URL] = [:] + @State private var exportFailures: Set = [] + @State private var exportError: AppError? private var isCompact: Bool { horizontalSizeClass == .compact } @@ -22,8 +24,8 @@ struct DownloadView: View { ContentUnavailableView("No download yet", systemImage: "arrow.down.circle") case .downloading(let progress, let downloaded, let total): progressView(progress: progress, downloaded: downloaded, total: total) - case .ready(let snapshots): - readyView(snapshots: snapshots) + case .ready(let snapshots, let warning): + readyView(snapshots: snapshots, warning: warning) case .failed(let message): ContentUnavailableView("Download failed", systemImage: "exclamationmark.triangle", @@ -56,10 +58,15 @@ struct DownloadView: View { // Build the CSV temp files up front so each row in the ready // view can hand a ShareLink a finished URL — avoids a second // tap on an Export CSV button to materialise them. - if case .ready(let snapshots) = viewModel?.phase { + if case .ready(let snapshots, _) = viewModel?.phase { await prepareExports(snapshots: snapshots) } } + .alert(item: $exportError) { err in + Alert(title: Text("CSV export failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } } @ViewBuilder @@ -99,7 +106,7 @@ struct DownloadView: View { } @ViewBuilder - private func readyView(snapshots: [MWSessionSnapshot]) -> some View { + private func readyView(snapshots: [MWSessionSnapshot], warning: String?) -> some View { VStack(spacing: isCompact ? 8 : 12) { HStack(spacing: 8) { Image(systemName: "checkmark.seal.fill") @@ -110,11 +117,23 @@ struct DownloadView: View { } .padding(.horizontal, 4) + if let warning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(Palette.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + // One glass card per session, matching the per-channel chart // card layout in LiveStreamView. GlassEffectContainer { ForEach(snapshots, id: \.id) { snap in - SessionDownloadCard(snapshot: snap, csvURL: exportItems[snap.id]) + SessionDownloadCard( + snapshot: snap, + csvURL: exportItems[snap.id], + exportFailed: exportFailures.contains(snap.id) + ) } } } @@ -122,15 +141,21 @@ struct DownloadView: View { private func prepareExports(snapshots: [MWSessionSnapshot]) async { var built: [UUID: URL] = [:] + var failures = Set() for snapshot in snapshots { - if let url = try? await CSVExporter.exportToTempFile( - store: appStore.persistence, - snapshot: snapshot - ) { + do { + let url = try await CSVExporter.exportToTempFile( + store: appStore.persistence, + snapshot: snapshot + ) built[snapshot.id] = url + } catch { + failures.insert(snapshot.id) + exportError = AppError(error: error) } } exportItems = built + exportFailures = failures } } @@ -141,6 +166,7 @@ struct DownloadView: View { private struct SessionDownloadCard: View { let snapshot: MWSessionSnapshot let csvURL: URL? + let exportFailed: Bool var body: some View { HStack(alignment: .center, spacing: 12) { @@ -165,6 +191,12 @@ private struct SessionDownloadCard: View { } .buttonStyle(.glass) .accessibilityLabel("Share CSV for \(snapshot.label ?? snapshot.sensorKind)") + } else if exportFailed { + Label("CSV unavailable", systemImage: "exclamationmark.triangle.fill") + .labelStyle(.iconOnly) + .font(.title3) + .foregroundStyle(Palette.warning) + .accessibilityLabel("CSV export failed for \(snapshot.label ?? snapshot.sensorKind)") } else { ProgressView() .controlSize(.small) diff --git a/Apps/MetaWear/MetaWear/Features/Logging/ExportSheet.swift b/Apps/MetaWear/MetaWear/Features/Logging/ExportSheet.swift index 067ae02..ce01b2b 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/ExportSheet.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/ExportSheet.swift @@ -1,11 +1,16 @@ import SwiftUI -struct ExportSheetItem: Identifiable { +struct ExportSheetItem: Identifiable, Sendable { let id = UUID() let url: URL let subtitle: String } +struct ExportResult: Identifiable, Sendable { + let id = UUID() + let items: [ExportSheetItem] +} + struct ExportSheet: View { let items: [ExportSheetItem] @Environment(\.dismiss) private var dismiss diff --git a/Apps/MetaWear/MetaWear/Features/Logging/LogSessionView.swift b/Apps/MetaWear/MetaWear/Features/Logging/LogSessionView.swift index 717a8ff..4b45605 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/LogSessionView.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/LogSessionView.swift @@ -150,7 +150,6 @@ struct LogSessionView: View { switch viewModel?.phase ?? .idle { case .idle: Button("Start", systemImage: "record.circle.fill") { - print("[Log] Start tapped — viewModel=\(viewModel != nil) selections=\(selections.count)") Task { await viewModel?.start(selections) // Refresh the AppStore's pending-sessions cache so the diff --git a/Apps/MetaWear/MetaWear/Features/Logging/LoggingPill.swift b/Apps/MetaWear/MetaWear/Features/Logging/LoggingPill.swift index b01d891..fab2999 100644 --- a/Apps/MetaWear/MetaWear/Features/Logging/LoggingPill.swift +++ b/Apps/MetaWear/MetaWear/Features/Logging/LoggingPill.swift @@ -44,6 +44,10 @@ struct LoggingPill: View { private func elapsed(from start: Date) -> String { let elapsed = Int(now.timeIntervalSince(start)) let m = elapsed / 60, s = elapsed % 60 - return String(format: "%02d:%02d", m, s) + return "\(twoDigits(m)):\(twoDigits(s))" + } + + private func twoDigits(_ value: Int) -> String { + value < 10 ? "0\(value)" : "\(value)" } } diff --git a/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift b/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift index c9922f2..380f39e 100644 --- a/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift +++ b/Apps/MetaWear/MetaWear/Features/Sessions/SessionDetailView.swift @@ -7,9 +7,8 @@ struct SessionDetailView: View { let snapshot: MWSessionSnapshot @Environment(AppStore.self) private var appStore @State private var preview: [AnyChartSample] = [] - @State private var loadError: AppError? - @State private var exportItems: [ExportSheetItem] = [] - @State private var showExport = false + @State private var lastError: AppError? + @State private var exportResult: ExportResult? var body: some View { ScrollView { @@ -36,8 +35,13 @@ struct SessionDetailView: View { .task { await loadPreview() } - .sheet(isPresented: $showExport) { - ExportSheet(items: exportItems) + .sheet(item: $exportResult) { result in + ExportSheet(items: result.items) + } + .alert(item: $lastError) { err in + Alert(title: Text("Session failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) } } @@ -61,18 +65,32 @@ struct SessionDetailView: View { case Quaternion.persistenceKind: let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: Quaternion.self) preview = samples.suffix(600).map(AnyChartSample.from) + case EulerAngles.persistenceKind: + let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: EulerAngles.self) + preview = samples.suffix(600).map(AnyChartSample.from) + case CorrectedCartesianFloat.persistenceKind: + let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: CorrectedCartesianFloat.self) + preview = samples.suffix(600).map(AnyChartSample.from) + case Float.persistenceKind: + let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: Float.self) + preview = samples.suffix(600).map(AnyChartSample.from) + case Bool.persistenceKind: + let samples = try await appStore.persistence.fetchSamples(sessionID: snapshot.id, as: Bool.self) + preview = samples.suffix(600).map(AnyChartSample.from) default: preview = [] } } catch { - loadError = AppError(error: error) + lastError = AppError(error: error) } } private func prepareExport() async { - if let url = try? await CSVExporter.exportToTempFile(store: appStore.persistence, snapshot: snapshot) { - exportItems = [ExportSheetItem(url: url, subtitle: "\(snapshot.sampleCount) samples")] - showExport = true + do { + let url = try await CSVExporter.exportToTempFile(store: appStore.persistence, snapshot: snapshot) + exportResult = ExportResult(items: [ExportSheetItem(url: url, subtitle: "\(snapshot.sampleCount) samples")]) + } catch { + lastError = AppError(error: error) } } } diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index e047856..a6590b9 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -15,6 +15,7 @@ struct DeviceSettingsView: View { @State private var logEntryCount: UInt32? @State private var activeLoggerCount: Int? @State private var isClearing = false + @State private var clearLogError: AppError? var body: some View { Form { @@ -91,6 +92,11 @@ struct DeviceSettingsView: View { Text("Any logger subscriptions and pending entries will be removed from the board.") } } + .alert(item: $clearLogError) { err in + Alert(title: Text("Clear logs failed"), + message: Text(err.message), + dismissButton: .default(Text("OK"))) + } } /// Read `LOG_LENGTH` and enumerate active loggers so the section @@ -109,10 +115,14 @@ struct DeviceSettingsView: View { guard let device = appStore.activeDevice else { return } isClearing = true defer { isClearing = false } - try? await device.clearLog() - deleteLocalPendingRecords(for: device.identifier) - appStore.refreshPendingLogSessions() - await refreshLogStats() + do { + try await device.clearLog() + deleteLocalPendingRecords(for: device.identifier) + appStore.refreshPendingLogSessions() + await refreshLogStats() + } catch { + clearLogError = AppError(error: error) + } } private func deleteLocalPendingRecords(for deviceID: UUID) { diff --git a/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift index 2049abb..32a1a78 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift @@ -26,6 +26,16 @@ final class ControlsViewModel { var isReadingPressure = false var isReadingLight = false + var motorDutyPercent: Int { + get { Int(motorDuty) } + set { motorDuty = UInt8(clamping: newValue) } + } + + var motorPulseMilliseconds: Int { + get { Int(motorPulseMs) } + set { motorPulseMs = UInt16(clamping: newValue) } + } + init(device: MetaWearDevice) { self.device = device } diff --git a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift index a6b5ea6..7903d94 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DeviceViewModel.swift @@ -18,7 +18,7 @@ final class DeviceViewModel { /// BMI160 vs BMI270 for the accelerometer), and their revision. var modules: [MWModule: MWModuleInfo] = [:] - private var batteryPollTask: Task? + @ObservationIgnored private let batteryPoller = BatteryPoller() init(device: MetaWearDevice, appStore: AppStore) { self.device = device @@ -99,17 +99,32 @@ final class DeviceViewModel { private func startBatteryPolling() { stopBatteryPolling() - batteryPollTask = Task { @MainActor [weak self] in + batteryPoller.task = Task { @MainActor [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) guard !Task.isCancelled else { return } - await self?.refreshBattery() + guard let self else { return } + await refreshBattery() } } } private func stopBatteryPolling() { - batteryPollTask?.cancel() - batteryPollTask = nil + batteryPoller.cancel() + } + + private final class BatteryPoller { + var task: Task? { + didSet { oldValue?.cancel() } + } + + deinit { + task?.cancel() + } + + func cancel() { + task?.cancel() + task = nil + } } } diff --git a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift index 55268aa..84cb22e 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/DownloadViewModel.swift @@ -15,7 +15,7 @@ final class DownloadViewModel { /// "123 / 456 entries (27%)" without waiting for the first /// firmware progress notification to arrive. case downloading(progress: Double, downloaded: Int, total: Int) - case ready(snapshots: [MWSessionSnapshot]) + case ready(snapshots: [MWSessionSnapshot], warning: String?) case failed(message: String) } @@ -44,7 +44,7 @@ final class DownloadViewModel { /// one sensor's data after logging two. func downloadAll(records: [LogSessionRecord]) async { guard !records.isEmpty else { - phase = .ready(snapshots: []) + phase = .ready(snapshots: [], warning: nil) return } phase = .downloading(progress: 0, downloaded: 0, total: 0) @@ -62,7 +62,14 @@ final class DownloadViewModel { // Enumerate the board's logger slots ONCE and share the result — // every enumeration ends with one timed-out probe, so per-record // enumeration multiplied that stall by the number of sensors. - let activeLoggers = (try? await device.queryActiveLoggers()) ?? [] + let activeLoggers: [ActiveLogger] + do { + activeLoggers = try await device.queryActiveLoggers() + } catch { + phase = .failed(message: error.localizedDescription) + lastError = AppError(error: error) + return + } for record in records { await recoverLoggers(for: record, chip: chip, active: activeLoggers) } @@ -80,6 +87,7 @@ final class DownloadViewModel { // 3. Decode + save per record. var snapshots: [MWSessionSnapshot] = [] + var keptBoardData = false for record in records { do { if let snap = try await decodeAndSave( @@ -92,25 +100,41 @@ final class DownloadViewModel { record.status = .downloaded } else { record.status = .stopped + keptBoardData = true } } catch { - record.status = .failed + record.status = .stopped + keptBoardData = true lastError = AppError(error: error) } } + try? containers.local.mainContext.save() + 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." + ) + return + } + // 4. Drop the on-flash entries + on-board logger triggers we just // drained. The readout in step 2 streamed the data over BLE // but didn't actually free anything on the board — `stopLogging` // only stops the sampling sensor, not the logger subscriptions. // Without this, the next `startLogging` would throw "already // being logged" (the registry still has these keys) and the - // board's 8 logger slots stay occupied. Best-effort: a failure - // here doesn't invalidate the downloaded snapshots. - try? await device.clearLog() - - try? containers.local.mainContext.save() - phase = .ready(snapshots: snapshots) + // board's 8 logger slots stay occupied. + do { + try await device.clearLog() + phase = .ready(snapshots: snapshots, warning: nil) + } catch { + lastError = AppError(error: error) + phase = .ready( + snapshots: snapshots, + warning: "Downloaded data was saved, but the board logs could not be cleared. Retry clearing from Settings before starting another logging session." + ) + } } /// Drain `device.downloadLogs()` into a single accumulated entries array, diff --git a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift index 6e99c1c..5faf0f1 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/LogSessionViewModel.swift @@ -28,34 +28,39 @@ final class LogSessionViewModel { } func start(_ selections: [SensorSelection]) async { - print("[Log] start phase=\(phase) selections=\(selections.count)") guard case .idle = phase else { - print("[Log] start refused — phase is not .idle") return } let context = containers.local.mainContext let modules = await device.modules let chip = MWSensorFusionChip(accImpl: modules[.accelerometer]?.implementation ?? 1) ?? .bmi160 - print("[Log] chip=\(chip) accImpl=\(modules[.accelerometer]?.implementation ?? 0)") var records: [LogSessionRecord] = [] do { for selection in selections { - print("[Log] startOne \(selection.id) hz=\(selection.hz) range=\(selection.range ?? -1)") if let record = try await startOne(selection: selection, chip: chip, context: context) { records.append(record) - print("[Log] record inserted") - } else { - print("[Log] startOne returned nil") } } try context.save() activeRecords = records phase = .running(startedAt: .now) startElapsedTimer() - print("[Log] phase -> running, records=\(records.count)") } catch { - print("[Log] start FAILED: \(error)") - lastError = AppError(error: error) + let stillRunning = await rollbackStartedRecords(records, chip: chip, context: context) + if stillRunning.isEmpty { + activeRecords = [] + phase = .idle + lastError = AppError(error: error) + } else { + activeRecords = stillRunning + let earliestStart = stillRunning.map(\.startDate).min() ?? .now + phase = .running(startedAt: earliestStart) + startElapsedTimer() + lastError = AppError(error: PartialStartRollbackError( + startError: error, + remainingCount: stillRunning.count + )) + } } } @@ -169,6 +174,25 @@ final class LogSessionViewModel { } } + private func rollbackStartedRecords( + _ records: [LogSessionRecord], + chip: MWSensorFusionChip, + context: ModelContext + ) async -> [LogSessionRecord] { + var stillRunning: [LogSessionRecord] = [] + for record in records { + do { + try await stopOne(record: record, chip: chip) + context.delete(record) + } catch { + record.status = .running + stillRunning.append(record) + } + } + try? context.save() + return stillRunning + } + /// Translate a user-facing Hz selection (one of 0.5, 1, 2, 5) into the /// board's timer period in ms. Clamps to ≥ 200 ms so a stray 0 from /// the picker can't generate a 0-period timer (which the firmware @@ -207,6 +231,16 @@ final class LogSessionViewModel { } } + private struct PartialStartRollbackError: LocalizedError { + let startError: Error + let remainingCount: Int + + var errorDescription: String? { + let noun = remainingCount == 1 ? "logger is" : "loggers are" + return "Logging did not start cleanly: \(startError.localizedDescription). \(remainingCount) \(noun) still running on the board; stop or download before starting another session." + } + } + // MARK: - Loggable factory /// Build a `MWLoggable` for the given selection, honouring the chosen diff --git a/Apps/MetaWear/MetaWearTests/CSVExporterTests.swift b/Apps/MetaWear/MetaWearTests/CSVExporterTests.swift new file mode 100644 index 0000000..1e12339 --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/CSVExporterTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +import MetaWear +import MetaWearPersistence +@testable import MetaWearApp + +@MainActor +@Suite("CSVExporter") +struct CSVExporterTests { + + @Test func exportsCorrectedCartesianSamples() async throws { + let container = try AppModelContainer.makeShared(inMemory: true).local + let store = MWPersistenceStore(modelContainer: container) + let info = MWDeviceInformation( + manufacturer: "MbientLab", + modelNumber: "MetaWear", + serialNumber: "TEST", + firmwareRevision: "1.0.0", + hardwareRevision: "1" + ) + let date = Date(timeIntervalSince1970: 1_747_655_528) + let samples = [ + MWLoggedSample( + date: date, + tickMs: 12.5, + value: CorrectedCartesianFloat(x: 1, y: 2, z: 3, accuracy: 2) + ) + ] + let snapshot = try await store.saveSession( + deviceID: UUID(), + deviceInfo: info, + sensorKind: CorrectedCartesianFloat.persistenceKind, + samples: samples, + label: "Fusion · Corrected Acceleration · 100 Hz" + ) + + let url = try await CSVExporter.exportToTempFile(store: store, snapshot: snapshot) + let csv = try String(contentsOf: url, encoding: .utf8) + + #expect(csv.contains("epoch,elapsed_ms,x,y,z,accuracy")) + #expect(csv.contains("12.500,1.000000,2.000000,3.000000,2")) + } +} diff --git a/Tests/MetaWearHardwareTests/ConnectivityTests.swift b/Tests/MetaWearHardwareTests/ConnectivityTests.swift index efe7446..fa21fd3 100644 --- a/Tests/MetaWearHardwareTests/ConnectivityTests.swift +++ b/Tests/MetaWearHardwareTests/ConnectivityTests.swift @@ -154,7 +154,7 @@ struct ConnectivityTests { try await withConnectedDevice { device in let info = await device.moduleInfo(for: .accelerometer) - print("\n Accelerometer is present: \(info?.isPresent, default: "false")\n") + print("\n Accelerometer is present: \(info?.isPresent ?? false)\n") #expect(info != nil) #expect(info?.isPresent == true)