Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Apps/MetaWear/MetaWear/App/AnyChartSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CorrectedCartesianFloat>) -> AnyChartSample {
AnyChartSample(time: s.date, f0: s.value.x, f1: s.value.y, f2: s.value.z, channelCount: 3)
}

static func from(_ s: MWLoggedSample<Float>) -> AnyChartSample {
AnyChartSample(time: s.date, f0: s.value, channelCount: 1)
}

static func from(_ s: MWLoggedSample<Bool>) -> AnyChartSample {
AnyChartSample(time: s.date, f0: s.value ? 1 : 0, channelCount: 1)
}
}
38 changes: 31 additions & 7 deletions Apps/MetaWear/MetaWear/App/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Apps/MetaWear/MetaWear/Export/CSVExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 18 additions & 8 deletions Apps/MetaWear/MetaWear/Export/LiveBufferCSVExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)"
}
}
}
31 changes: 21 additions & 10 deletions Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: {
Expand All @@ -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
}
}

Expand Down
28 changes: 20 additions & 8 deletions Apps/MetaWear/MetaWear/Features/LiveStream/LiveStreamView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
}
50 changes: 41 additions & 9 deletions Apps/MetaWear/MetaWear/Features/Logging/DownloadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []
@State private var exportError: AppError?

private var isCompact: Bool { horizontalSizeClass == .compact }

Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -110,27 +117,45 @@ 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)
)
}
}
}
}

private func prepareExports(snapshots: [MWSessionSnapshot]) async {
var built: [UUID: URL] = [:]
var failures = Set<UUID>()
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
}
}

Expand All @@ -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) {
Expand All @@ -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)
Expand Down
Loading
Loading