diff --git a/.gitignore b/.gitignore index 5633e45..9a519b0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ AGENTS.md .vscode/ .idea/ *.swp + +# Local-only App Store submission collateral — never pushed +Apps/MetaWear/AppStore/ diff --git a/Apps/MetaWear/Info.plist b/Apps/MetaWear/Info.plist index 2f83a77..88fd70e 100644 --- a/Apps/MetaWear/Info.plist +++ b/Apps/MetaWear/Info.plist @@ -15,13 +15,23 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + 10.0 CFBundleVersion 1 + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription MetaWear needs Bluetooth to connect to and stream from your sensor. + UIBackgroundModes + + remote-notification + + UIRequiredDeviceCapabilities + + bluetooth-le + UILaunchScreen UISupportedInterfaceOrientations diff --git a/Apps/MetaWear/MetaWear.entitlements b/Apps/MetaWear/MetaWear.entitlements index 4f6442c..8240511 100644 --- a/Apps/MetaWear/MetaWear.entitlements +++ b/Apps/MetaWear/MetaWear.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.developer.icloud-container-identifiers iCloud.com.mbientlab.MetaWear diff --git a/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..7e306ac Binary files /dev/null and b/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/Contents.json b/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..f22e10c 100644 --- a/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Apps/MetaWear/MetaWear/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,28 +1,7 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], + "filename" : "AppIcon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift b/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift index b6046cc..028b2e0 100644 --- a/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift @@ -49,26 +49,17 @@ struct ControlsView: View { } Section("Quick Reads") { - QuickReadRow( - title: "Temperature", - icon: "thermometer.medium", - 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 { Self.formattedMeasurement($0 / 100, unit: "hPa") }, - isLoading: viewModel.isReadingPressure - ) { Task { await viewModel.readPressure() } } - - QuickReadRow( - title: "Ambient Light", - icon: "sun.max", - value: viewModel.ambientLightLux.map { Self.formattedMeasurement($0, unit: "lux") }, - isLoading: viewModel.isReadingLight - ) { Task { await viewModel.readAmbientLight() } } + // Driven by the board's discovered modules: each one-shot- + // readable sensor appears only when present (temperature is on + // every board; pressure / ambient-light depend on the model). + ForEach(quickReads.filter { viewModel.availableModules.contains($0.module) }) { spec in + QuickReadRow( + title: spec.title, + icon: spec.icon, + value: spec.value(viewModel), + isLoading: spec.isLoading(viewModel) + ) { Task { await spec.read(viewModel) } } + } } Section("Haptic") { @@ -100,6 +91,7 @@ struct ControlsView: View { if viewModel == nil { viewModel = ControlsViewModel(device: device) } + await viewModel?.loadModules() } .alert(item: Binding( get: { viewModel?.lastError }, @@ -114,6 +106,39 @@ struct ControlsView: View { private static func formattedMeasurement(_ value: Float, unit: String) -> String { value.formatted(.number.precision(.fractionLength(1))) + " " + unit } + + /// One-shot-readable sensors offered in Quick Reads, each gated on its module + /// being present on the connected board. Add a spec to offer another one-shot + /// read — no other view change needed. + private var quickReads: [QuickReadSpec] { + [ + QuickReadSpec(id: "temperature", title: "Temperature", icon: "thermometer.medium", module: .temperature, + value: { vm in vm.temperatureC.map { Self.formattedMeasurement($0, unit: "°C") } }, + isLoading: { $0.isReadingTemperature }, + read: { vm in await vm.readTemperature() }), + QuickReadSpec(id: "pressure", title: "Pressure", icon: "barometer", module: .barometer, + value: { vm in vm.pressurePa.map { Self.formattedMeasurement($0 / 100, unit: "hPa") } }, + isLoading: { $0.isReadingPressure }, + read: { vm in await vm.readPressure() }), + QuickReadSpec(id: "ambientLight", title: "Ambient Light", icon: "sun.max", module: .ambientLight, + value: { vm in vm.ambientLightLux.map { Self.formattedMeasurement($0, unit: "lux") } }, + isLoading: { $0.isReadingLight }, + read: { vm in await vm.readAmbientLight() }), + ] + } +} + +/// Describes one Quick Reads row: which board module gates it, plus how to read +/// the value, format it, and reflect the in-flight state. Keeps the section's +/// presence-gating uniform instead of hardcoding each sensor. +private struct QuickReadSpec: Identifiable { + let id: String + let title: String + let icon: String + let module: MWModule + let value: (ControlsViewModel) -> String? + let isLoading: (ControlsViewModel) -> Bool + let read: (ControlsViewModel) async -> Void } /// One row in the Quick Reads section. Shows the sensor name + icon, the diff --git a/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift b/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift index 70b40d1..4d42438 100644 --- a/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift +++ b/Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift @@ -4,11 +4,9 @@ import MetaWear /// Live 3D orientation visualisation driven by quaternion samples. /// -/// Loads `MetaMotion.usdz` from the app bundle when present — drop a USDZ -/// file (converted from the MetaMotion STEP via Reality Converter or Blender's -/// USD exporter) into the Xcode project to replace the procedural placeholder. -/// The placeholder is a rounded white box approximating a MetaMotion S -/// (≈24 × 12 × 33 mm) with a subtle LED accent strip on the front face. +/// Loads `MetaMotion.usdz` from the app bundle when present. Otherwise it uses +/// a procedural MetaMotion-style rectangular board so the live orientation view +/// still reads as real MetaWear hardware. struct QuaternionRealityView: View { let latest: AnyChartSample? @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -41,7 +39,7 @@ struct QuaternionRealityView: View { entity.orientation = q } else { entity.move( - to: Transform(scale: .one, rotation: q, translation: entity.position), + to: Transform(scale: entity.scale, rotation: q, translation: entity.position), relativeTo: entity.parent, duration: 0.05, timingFunction: .linear @@ -57,9 +55,9 @@ struct QuaternionRealityView: View { /// Build the entity placed at scene origin. If a `MetaMotion.usdz` resource /// ships in the bundle it's loaded and re-centered; otherwise we build a - /// procedural MetaMotion-shaped placeholder. + /// procedural MetaMotion-style rectangular board. /// - /// TODO: Ship the real MetaMotion 3D model. + /// To ship the real CAD-derived model later: /// 1. Convert the STEP file → USDZ using Apple's Reality Converter /// (drag-and-drop STEP, export USDZ) or Blender's USD exporter. /// 2. Drag `MetaMotion.usdz` into the Xcode project and confirm it's a @@ -72,51 +70,98 @@ struct QuaternionRealityView: View { private static func makeEntity() async -> Entity { if let url = Bundle.main.url(forResource: "MetaMotion", withExtension: "usdz"), let entity = try? await Entity(contentsOf: url) { - recenterAndScale(entity, targetLongestEdge: 0.18) - entity.position = [0, 0, -0.35] + recenterAndScale(entity, targetLongestEdge: 1.25) + entity.position = [0, 0, -0.45] return entity } - return makeProceduralBoard() + return makeMetaMotionBoard() } - /// Procedural MetaMotion-S placeholder. Proportions match the real - /// 24 × 12 × 33 mm board, scaled up so it reads at scene scale. White ABS - /// finish with a thin teal accent on the leading edge for the LED. - private static func makeProceduralBoard() -> Entity { + /// MetaBase-style rectangular MetaMotion model with front-panel details. + private static func makeMetaMotionBoard() -> Entity { let parent = Entity() - let w: Float = 0.10 // width (matches 24 mm) - let h: Float = 0.05 // depth (matches 12 mm, scaled for visual presence) - let d: Float = 0.138 // length (matches 33 mm) + let shell = material(white: 0.92, roughness: 0.48) + let highlight = material(white: 0.985, roughness: 0.38) + let dot = material(white: 0.70, roughness: 0.58) + let edge = material(white: 0.62, roughness: 0.70) - var body = PhysicallyBasedMaterial() - body.baseColor = .init(tint: .init(white: 0.92, alpha: 1.0)) - body.roughness = 0.45 - body.metallic = 0.0 - let bodyMesh = MeshResource.generateBox(width: w, height: h, depth: d, cornerRadius: 0.014) - let board = ModelEntity(mesh: bodyMesh, materials: [body]) - parent.addChild(board) + addRoundedBox( + to: parent, + width: 0.090, + height: 0.142, + depth: 0.020, + cornerRadius: 0.024, + position: [0, 0.002, 0], + material: shell + ) + addRoundedBox( + to: parent, + width: 0.070, + height: 0.104, + depth: 0.004, + cornerRadius: 0.018, + position: [0, -0.004, 0.012], + material: highlight + ) + addDisc(to: parent, radius: 0.012, position: [-0.030, 0.049, 0.016], material: dot) + addDisc(to: parent, radius: 0.005, position: [-0.029, -0.050, 0.016], material: dot) - var accent = PhysicallyBasedMaterial() - accent.baseColor = .init(tint: .init(red: 0.22, green: 0.62, blue: 0.78, alpha: 1.0)) - accent.emissiveColor = .init(color: .init(red: 0.30, green: 0.78, blue: 0.95, alpha: 1.0)) - accent.emissiveIntensity = 0.6 - let ledMesh = MeshResource.generateBox(width: w * 0.18, height: h * 0.4, depth: d * 0.04, cornerRadius: 0.002) - let led = ModelEntity(mesh: ledMesh, materials: [accent]) - led.position = [w * 0.32, h * 0.5 + 0.0005, d * 0.45] - parent.addChild(led) + addRoundedBox( + to: parent, + width: 0.040, + height: 0.008, + depth: 0.024, + cornerRadius: 0.003, + position: [0, -0.073, 0], + material: edge + ) - var port = PhysicallyBasedMaterial() - port.baseColor = .init(tint: .init(white: 0.18, alpha: 1.0)) - port.roughness = 0.6 - let portMesh = MeshResource.generateBox(width: w * 0.38, height: h * 0.45, depth: d * 0.03, cornerRadius: 0.003) - let port3d = ModelEntity(mesh: portMesh, materials: [port]) - port3d.position = [0, 0, -d * 0.5] - parent.addChild(port3d) - - parent.position = [0, 0, -0.35] + parent.scale = [8.8, 8.8, 8.8] + parent.position = [0, 0, -0.45] return parent } + private static func addRoundedBox( + to parent: Entity, + width: Float, + height: Float, + depth: Float, + cornerRadius: Float, + position: SIMD3, + material: PhysicallyBasedMaterial + ) { + let mesh = MeshResource.generateBox( + width: width, + height: height, + depth: depth, + cornerRadius: cornerRadius + ) + let entity = ModelEntity(mesh: mesh, materials: [material]) + entity.position = position + parent.addChild(entity) + } + + private static func addDisc( + to parent: Entity, + radius: Float, + position: SIMD3, + material: PhysicallyBasedMaterial + ) { + let mesh = MeshResource.generateCylinder(height: 0.003, radius: radius) + let entity = ModelEntity(mesh: mesh, materials: [material]) + entity.orientation = simd_quatf(angle: .pi / 2, axis: [1, 0, 0]) + entity.position = position + parent.addChild(entity) + } + + private static func material(white: CGFloat, roughness: Float) -> PhysicallyBasedMaterial { + var material = PhysicallyBasedMaterial() + material.baseColor = .init(tint: .init(white: white, alpha: 1.0)) + material.roughness = .init(floatLiteral: roughness) + material.metallic = 0.0 + return material + } + /// Normalise an externally-loaded entity: place its centre at the origin /// and scale so its longest bounding edge equals `targetLongestEdge`. private static func recenterAndScale(_ entity: Entity, targetLongestEdge: Float) { diff --git a/Apps/MetaWear/MetaWear/Features/LiveStream/SensorChartView.swift b/Apps/MetaWear/MetaWear/Features/LiveStream/SensorChartView.swift index 5db0c45..1848a78 100644 --- a/Apps/MetaWear/MetaWear/Features/LiveStream/SensorChartView.swift +++ b/Apps/MetaWear/MetaWear/Features/LiveStream/SensorChartView.swift @@ -18,6 +18,9 @@ struct SensorChartView: View { header SensorReadoutChips(channels: axisStyle.channels, latest: latest, unit: axisStyle.unit) chart + // Extra breathing room between the live x/y/z readout and the + // top of the graph, beyond the VStack's uniform spacing. + .padding(.top, isCompact ? 6 : 8) } .glassCard(padding: isCompact ? 10 : 16) } diff --git a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift index a6590b9..7c934b5 100644 --- a/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift +++ b/Apps/MetaWear/MetaWear/Features/Settings/DeviceSettingsView.swift @@ -1,10 +1,12 @@ import SwiftUI import SwiftData import MetaWear +import MetaWearFirmware struct DeviceSettingsView: View { @Environment(AppStore.self) private var appStore @State private var viewModel: DeviceViewModel? + @State private var firmware: FirmwareUpdateViewModel? @State private var draftName: String = "" @State private var showFactoryResetConfirm = false @State private var showClearLogsConfirm = false @@ -28,6 +30,10 @@ struct DeviceSettingsView: View { .disabled(draftName.isEmpty) } + if let firmware { + FirmwareSection(viewModel: firmware) + } + Section { LabeledContent("Log Entries") { Text(logEntryCount.map { "\($0)" } ?? "—") @@ -66,6 +72,15 @@ struct DeviceSettingsView: View { viewModel = DeviceViewModel(device: device, appStore: appStore) await viewModel?.refreshAfterConnect() } + // Firmware update is a real-hardware operation — it queries + // MbientLab's catalog and flashes over DFU — so the section is + // hidden for the simulated Demo Mode board: there's nothing to + // flash, and the catalog lookup would just error on its synthetic + // firmware revision. + if firmware == nil, device.identifier != DemoBLETransport.deviceIdentifier { + firmware = FirmwareUpdateViewModel(device: device, appStore: appStore) + await firmware?.loadCurrentVersion() + } await refreshLogStats() } .confirmationDialog("Factory reset this MetaWear?", @@ -136,3 +151,150 @@ struct DeviceSettingsView: View { } } } + +// MARK: - Firmware + +/// Settings section that surfaces the board's firmware version, checks +/// MbientLab's catalog for a newer build, and runs an over-the-air DFU update +/// with a live progress readout. All state lives in `FirmwareUpdateViewModel`; +/// this view just maps `phase` onto rows. +private struct FirmwareSection: View { + let viewModel: FirmwareUpdateViewModel + @State private var showUpdateConfirm = false + + var body: some View { + Section { + LabeledContent("Installed") { + Text(viewModel.currentVersion ?? "—") + .monospacedDigit() + .foregroundStyle(.secondary) + } + content + } header: { + Text("Firmware") + } footer: { + footer + } + .confirmationDialog("Update firmware?", + isPresented: $showUpdateConfirm, + titleVisibility: .visible) { + Button("Update") { + Task { await viewModel.startUpdate() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Keep MetaWear open with the board nearby and powered until the update finishes. The board restarts automatically when it's done.") + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.phase { + case .unknown: + Button("Check for Updates", systemImage: "arrow.triangle.2.circlepath") { + Task { await viewModel.checkForUpdate() } + } + + case .checking: + HStack { + Text("Checking for updates…") + .foregroundStyle(.secondary) + Spacer() + ProgressView().controlSize(.small) + } + + case .upToDate: + LabeledContent("Status") { + Label("Up to date", systemImage: "checkmark.circle.fill") + .labelStyle(.titleAndIcon) + .foregroundStyle(.green) + } + Button("Check Again", systemImage: "arrow.triangle.2.circlepath") { + Task { await viewModel.checkForUpdate() } + } + + case .updateAvailable(let build): + LabeledContent("Latest") { + Text(build.firmwareRev) + .monospacedDigit() + .foregroundStyle(.secondary) + } + Button("Update Firmware", systemImage: "square.and.arrow.down") { + showUpdateConfirm = true + } + + case .updating(let progress): + FirmwareProgressView(progress: progress) + + case .completed: + LabeledContent("Status") { + Label("Updated", systemImage: "checkmark.circle.fill") + .labelStyle(.titleAndIcon) + .foregroundStyle(.green) + } + + case .failed(let message): + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Button("Try Again", systemImage: "arrow.triangle.2.circlepath") { + Task { await viewModel.checkForUpdate() } + } + } + } + + @ViewBuilder + private var footer: some View { + switch viewModel.phase { + case .updating: + Text("Updating firmware. Keep the app open with the board nearby and powered — do not disconnect.") + case .updateAvailable: + Text("Downloads the latest firmware from MbientLab and installs it over Bluetooth. The board restarts when finished.") + default: + EmptyView() + } + } +} + +/// One row showing the active DFU phase plus a determinate progress bar while +/// firmware bytes are actually transferring (`percentComplete` is only +/// meaningful during `.uploading`; every other phase shows a small spinner). +private struct FirmwareProgressView: View { + let progress: DFUProgress + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + if progress.state == .uploading { + Text("\(Int(progress.percentComplete))%") + .monospacedDigit() + .foregroundStyle(.secondary) + } else { + ProgressView().controlSize(.small) + } + } + if progress.state == .uploading { + ProgressView(value: progress.percentComplete, total: 100) + } + } + .padding(.vertical, 4) + } + + private var label: String { + switch progress.state { + case .fetchingCatalog: return "Checking catalog…" + case .downloadingFirmware: return "Downloading firmware…" + case .bootloaderHandoff: return "Preparing device…" + case .scanning: return "Locating device…" + case .connecting: return "Connecting…" + case .starting: return "Starting transfer…" + case .validating: return "Validating…" + case .uploading: return "Installing…" + case .disconnecting: return "Finishing up…" + case .completed: return "Complete" + case .aborted: return "Aborted" + } + } +} diff --git a/Apps/MetaWear/MetaWear/Persistence/AppContainers.swift b/Apps/MetaWear/MetaWear/Persistence/AppContainers.swift index 9ae3fea..0abb038 100644 --- a/Apps/MetaWear/MetaWear/Persistence/AppContainers.swift +++ b/Apps/MetaWear/MetaWear/Persistence/AppContainers.swift @@ -1,6 +1,11 @@ import SwiftData struct AppContainers { + /// CloudKit-backed, local-first store for lightweight device bookmarks. + /// + /// This container intentionally owns only `RememberedDevice`; session + /// samples never enter CloudKit. let cloud: ModelContainer + /// Local-only store for high-volume app data: logs, sessions, and samples. let local: ModelContainer } diff --git a/Apps/MetaWear/MetaWear/Persistence/AppModelContainer.swift b/Apps/MetaWear/MetaWear/Persistence/AppModelContainer.swift index bf9b9c8..b9e08e5 100644 --- a/Apps/MetaWear/MetaWear/Persistence/AppModelContainer.swift +++ b/Apps/MetaWear/MetaWear/Persistence/AppModelContainer.swift @@ -4,24 +4,58 @@ import MetaWearPersistence enum AppModelContainer { static func makeShared(inMemory: Bool = false) throws -> AppContainers { - // v1: single local-only container. CloudKit for RememberedDevice is deferred - // until the container schema is initialized in CloudKit Dashboard. To split - // again later, give each ModelConfiguration a unique `name:` so they don't - // both default to "default.store". + try AppContainers( + cloud: makeRememberedDeviceContainer(inMemory: inMemory), + local: makeLocalSessionContainer(inMemory: inMemory) + ) + } + + private static func makeRememberedDeviceContainer(inMemory: Bool) throws -> ModelContainer { + let schema = Schema([RememberedDevice.self]) + let cloudKitDatabase: ModelConfiguration.CloudKitDatabase = inMemory ? .none : .automatic + let configuration = ModelConfiguration( + "RememberedDevices", + schema: schema, + isStoredInMemoryOnly: inMemory, + cloudKitDatabase: cloudKitDatabase + ) + + do { + return try ModelContainer(for: schema, configurations: configuration) + } catch where !inMemory { + // iCloud backup is deliberately best-effort. If the CloudKit-backed + // store can't initialize (account/capability problems), reopen the + // SAME on-disk store locally with CloudKit off. Reusing the same + // configuration name ("RememberedDevices") matters: a different name + // points at a separate SQLite file, orphaning any already-synced + // remembered devices in a divergent store. + let fallback = ModelConfiguration( + "RememberedDevices", + schema: schema, + isStoredInMemoryOnly: false, + cloudKitDatabase: .none + ) + return try ModelContainer(for: schema, configurations: fallback) + } + } + + private static func makeLocalSessionContainer(inMemory: Bool) throws -> ModelContainer { + // Keep high-volume telemetry out of CloudKit. Local SwiftData owns all + // sessions, samples, and active logging records so live streaming and + // downloads stay fast regardless of iCloud availability. let schema = Schema([ - RememberedDevice.self, MWSessionRecord.self, MWSampleRecord.self, LogSessionRecord.self ]) - let container = try ModelContainer( + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: inMemory, + cloudKitDatabase: .none + ) + return try ModelContainer( for: schema, - configurations: ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: inMemory, - cloudKitDatabase: .none - ) + configurations: configuration ) - return AppContainers(cloud: container, local: container) } } diff --git a/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift b/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift index 136e117..69fed98 100644 --- a/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift +++ b/Apps/MetaWear/MetaWear/Persistence/LogSessionRecord.swift @@ -3,13 +3,17 @@ import SwiftData @Model final class LogSessionRecord { - @Attribute(.unique) var id: UUID - var deviceID: UUID - var sensorKind: String - var configJSON: String - var loggerKey: String - var startDate: Date - var statusRaw: String + // CloudKit-compatible: no unique constraint, every stored property is + // optional or defaulted. Uniqueness of `id` is guaranteed by the app + // assigning a fresh UUID per session; lookups use a UUID predicate. + var id: UUID = UUID() + var deviceID: UUID = UUID() + var sensorKind: String = "" + var configJSON: String = "" + var loggerKey: String = "" + var startDate: Date = Date.distantPast + /// Default must equal `Status.running.rawValue`. + var statusRaw: String = "running" /// JSON-encoded `MWPolledLoggerHandles` for `temperature` / `humidity` /// sessions where the board-side timer + event + logger chain must be /// remembered across app restarts. Nil for natively-loggable sensors, diff --git a/Apps/MetaWear/MetaWear/PrivacyInfo.xcprivacy b/Apps/MetaWear/MetaWear/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..62df036 --- /dev/null +++ b/Apps/MetaWear/MetaWear/PrivacyInfo.xcprivacy @@ -0,0 +1,38 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/Apps/MetaWear/MetaWear/ViewModels/Channel.swift b/Apps/MetaWear/MetaWear/ViewModels/Channel.swift index d78050b..9a45b13 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/Channel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/Channel.swift @@ -23,11 +23,25 @@ final class Channel: Identifiable { // observed fields below, so the UI updates at a steady 30 fps no // matter how fast the board is sampling. - /// Capture buffer for the chart. Appended-to from the BLE consume task, - /// snapshotted into `displayBuffer` by the throttle loop. + /// Full-resolution capture buffer. Appended-to from the BLE consume task; + /// drives archive-to-history, `latest`, and the true-rate readout. @ObservationIgnored var ring: RingBuffer + /// Capped, *decimated* copy of `ring` — the actual plotted series. Fed one + /// real sample at a time (1 of every `displayStride`) and only ever + /// appended to, so older points scroll FIFO and keep their values instead + /// of jumping around the way a per-frame re-downsample would. The throttle + /// loop snapshots it into `displayBuffer`. + @ObservationIgnored + var displayRing: RingBuffer + + /// Keep 1 of every `displayStride` samples in `displayRing`. A live chart + /// card resolves only a few hundred points, so a high-rate sensor (e.g. + /// 200 Hz accel) is thinned to ~30 plotted Hz; low-rate sensors keep every + /// sample (stride 1). Set once from the configured rate. + let displayStride: Int + /// Running count of samples received from the consume task. The /// throttle loop copies this into the observed `totalSamples`. @ObservationIgnored @@ -49,5 +63,21 @@ final class Channel: Identifiable { self.id = selection.id self.selection = selection self.ring = RingBuffer(capacity: capacity) + // Plot at most ~30 Hz: thin high-rate sensors, keep low-rate ones whole. + self.displayStride = max(1, Int((selection.hz / 30).rounded())) + self.displayRing = RingBuffer(capacity: 180) + } + + /// Ingest a freshly-received sample. Always stored full-resolution in + /// `ring`; every `displayStride`-th sample is also mirrored into the + /// plotted `displayRing`. Both buffers are `@ObservationIgnored`, so this + /// stays off SwiftUI's per-sample invalidation path — the throttle loop + /// publishes to the observed fields at a fixed cadence. + func ingest(_ sample: AnyChartSample) { + ring.append(sample) + receivedCount &+= 1 + if receivedCount % displayStride == 0 { + displayRing.append(sample) + } } } diff --git a/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift index 5a0b159..694a98f 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/ControlsViewModel.swift @@ -30,6 +30,13 @@ final class ControlsViewModel { var isReadingPressure = false var isReadingLight = false + /// Modules the connected board reported present during discovery. The Quick + /// Reads section is driven by this set: each one-shot-readable sensor + /// (temperature, pressure, ambient light) is offered only when its module is + /// present, so a board without a given sensor doesn't show a read that would + /// just time out. + var availableModules: Set = [] + var motorDutyPercent: Int { get { Int(motorDuty) } set { motorDuty = UInt8(clamping: newValue) } @@ -44,6 +51,14 @@ final class ControlsViewModel { self.device = device } + /// Refresh `availableModules` from the device's discovered module set so + /// the UI can hide reads the board doesn't physically support. Mirrors the + /// gating in `SensorConfigView` / `LogSessionView`. + func loadModules() async { + let mods = await device.modules + availableModules = Set(mods.compactMap { $0.value.isPresent ? $0.key : nil }) + } + func playLED() async { do { try await device.send(MWLED.SetPattern(color: ledColor, pattern: ledPattern)) @@ -125,16 +140,26 @@ final class ControlsViewModel { measurementRate: .ms100) do { let stream = try await device.startStream(sensor) - var lastRaw: UInt32 = 0 - var seen = 0 - for try await sample in stream { - lastRaw = sample.value - seen += 1 - if sample.value > 0 || seen >= 10 { break } + // Once the stream has started it MUST be torn down on every exit, + // including the throwing one (mid-read disconnect / malformed + // packet). `defer` can't await, so use an inner do/catch and tear + // down unconditionally after it — otherwise the LTR329 stays + // enabled and its active-stream entry blocks every later read. + do { + var lastRaw: UInt32 = 0 + var seen = 0 + for try await sample in stream { + lastRaw = sample.value + seen += 1 + if sample.value > 0 || seen >= 10 { break } + } + ambientLightLux = Float(lastRaw) / 1000 + } catch { + lastError = AppError(error: error) } - ambientLightLux = Float(lastRaw) / 1000 try? await device.stopStreaming(sensor) } catch { + // `startStream` itself failed — nothing was enabled to tear down. lastError = AppError(error: error) } } diff --git a/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift new file mode 100644 index 0000000..1682889 --- /dev/null +++ b/Apps/MetaWear/MetaWear/ViewModels/FirmwareUpdateViewModel.swift @@ -0,0 +1,135 @@ +import Foundation +import Observation +import MetaWear +import MetaWearFirmware + +/// Presentation model for the Settings → Firmware section. +/// +/// Wraps the `MetaWearFirmware` DFU API: it checks MbientLab's release catalog +/// for a build newer than the one on the board, and drives an over-the-air +/// update while surfacing `DFUProgress` to the UI. A successful flash reboots +/// the board, which leaves the actor's cached `deviceInfo`/`modules` stale — so +/// this model reconnects through `AppStore` afterwards to refresh them. +@Observable +@MainActor +final class FirmwareUpdateViewModel { + + /// One coarse UI state for the section. Associated values carry exactly + /// what each state needs to render — the available build to offer, or the + /// live transfer progress to chart. + enum Phase: Equatable { + /// Haven't asked the catalog yet. + case unknown + /// Catalog lookup in flight. + case checking + /// Catalog says the board is already on the latest build. + case upToDate + /// A newer build is available. + case updateAvailable(MWFirmwareBuild) + /// Flash in progress; the value is the latest `DFUProgress` event. + case updating(DFUProgress) + /// Flash finished and we've reconnected to the (now-updated) board. + case completed + /// Something failed; the value is a user-facing message. + case failed(String) + } + + private let device: MetaWearDevice + private let appStore: AppStore + + /// Firmware revision currently on the board (from the Device Information + /// service), e.g. `"1.7.3"`. Refreshed after a successful update. + private(set) var currentVersion: String? + private(set) var phase: Phase = .unknown + + init(device: MetaWearDevice, appStore: AppStore) { + self.device = device + self.appStore = appStore + } + + /// True while a check or flash is in flight — used to gate other controls. + var isBusy: Bool { + switch phase { + case .checking, .updating: return true + default: return false + } + } + + /// Mirror the board's current firmware revision into `currentVersion`. + /// Cheap (reads the cached `deviceInfo`); safe to call on appear. + func loadCurrentVersion() async { + currentVersion = await device.deviceInfo?.firmwareRevision + } + + /// Ask MbientLab's catalog whether a newer build exists for this board. + func checkForUpdate() async { + phase = .checking + do { + if let build = try await device.checkForFirmwareUpdate() { + phase = .updateAvailable(build) + } else { + phase = .upToDate + } + } catch { + phase = .failed(message(for: error)) + } + } + + /// Flash the latest catalog build, streaming progress into `phase`, then + /// reconnect to pick up the new firmware. + /// + /// Requires the board to be idle (no active stream/log/download). We check + /// that up front so the user gets a clear message instead of a failure + /// mid-handoff — by which point the board has already been told to jump to + /// the bootloader. + func startUpdate() async { + guard case .idle = await device.state else { + phase = .failed(MWFirmwareError.deviceNotIdle.errorDescription + ?? "The board must be idle to update firmware.") + return + } + + var sawCompleted = false + do { + for try await progress in device.updateFirmwareToLatest() { + phase = .updating(progress) + if progress.state == .completed { sawCompleted = true } + } + } catch { + // A failure before the bootloader handoff (catalog/download) leaves + // the board connected and fine; one after it leaves the board in + // bootloader mode. We don't auto-reconnect here — a normal connect + // against a bootloader-mode peripheral can stall — so we just + // report and let the user reconnect from the scan screen. + phase = .failed(message(for: error)) + return + } + + guard sawCompleted else { + // Stream finished without a completion event — nothing was flashed + // (the catalog had nothing newer). Reflect that honestly rather + // than claiming success. + phase = .upToDate + return + } + + await reconnect() + await loadCurrentVersion() + phase = .completed + } + + /// Re-establish a coherent connection after the board reboots out of DFU. + /// + /// The flash path disconnects the board internally, so `AppStore`'s + /// `connectionState` is stale (still "connected"). Reset it to + /// `.disconnected` first so `connect(to:)` runs a full re-handshake — which + /// repopulates `deviceInfo`/`modules` — instead of early-returning. + private func reconnect() async { + appStore.connectionState = .disconnected + await appStore.connect(to: device) + } + + private func message(for error: Error) -> String { + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } +} diff --git a/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift b/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift index 7be6785..32dd727 100644 --- a/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift +++ b/Apps/MetaWear/MetaWear/ViewModels/StreamSessionViewModel.swift @@ -73,11 +73,20 @@ final class StreamSessionViewModel { func stop() async { isStreaming = false isPaused = false - startedAt = nil throttleTask?.cancel() throttleTask = nil await tearDownStreams() + // Persist the captured buffers as part of Stop — not only in + // `onDisappear`. Otherwise tapping Stop and then backgrounding or + // killing the app (instead of navigating back) silently loses the + // just-captured session, even though archive-to-history is a feature. + // `archiveToHistory()` is idempotent (`hasArchived`), so the later + // `onDisappear` call becomes a no-op. `startedAt` stays set until after + // the archive so the saved sample ticks keep the real session start. + await archiveToHistory() + + startedAt = nil selections = [] } @@ -317,8 +326,7 @@ final class StreamSessionViewModel { // every 33 ms. Touching the observed fields // directly here would fire a SwiftUI invalidation // per sample and stall the UI at high sample rates. - channel.ring.append(any) - channel.receivedCount &+= 1 + channel.ingest(any) } } catch is CancellationError { return @@ -347,8 +355,7 @@ final class StreamSessionViewModel { guard let self, self.isStreaming, !self.isPaused else { continue } let any = convert(sample) // Non-observed write — throttle loop publishes. - channel.ring.append(any) - channel.receivedCount &+= 1 + channel.ingest(any) } } catch is CancellationError { return @@ -377,7 +384,13 @@ final class StreamSessionViewModel { // ms), instead of three per sample on the consume // task. At 100 Hz × 3 sensors that's ~30 ticks/sec // total UI churn rather than ~900 invalidations/sec. - channel.displayBuffer = elements + // + // `displayRing` is already decimated to a screen- + // resolvable point count and only ever appended to, so + // snapshotting it yields a smooth scrolling trace whose + // older points never move. `latest` and the archived + // `ring` stay full-resolution. + channel.displayBuffer = channel.displayRing.elements channel.latest = elements.last channel.effectiveHz = Self.effectiveHz(from: elements) channel.totalSamples = received diff --git a/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj b/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj index d5d5ace..cb89601 100644 --- a/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj +++ b/Apps/MetaWear/MetaWearApp.xcodeproj/project.pbxproj @@ -337,7 +337,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -395,7 +395,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -487,7 +487,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = S2273LZ6GL; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mbientlab.MetaWearTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -509,7 +509,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = S2273LZ6GL; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mbientlab.MetaWearTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Apps/MetaWear/MetaWearTests/AppModelContainerTests.swift b/Apps/MetaWear/MetaWearTests/AppModelContainerTests.swift new file mode 100644 index 0000000..6bedd78 --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/AppModelContainerTests.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftData +import Testing +import MetaWearPersistence +@testable import MetaWearApp + +@Suite("App model containers") +@MainActor +struct AppModelContainerTests { + + @Test func separatesRememberedDevicesFromSessionStorage() throws { + let containers = try AppModelContainer.makeShared(inMemory: true) + + #expect(containers.cloud !== containers.local) + + let rememberedContext = containers.cloud.mainContext + let localContext = containers.local.mainContext + + let deviceID = UUID() + let remembered = RememberedDevice( + peripheralUUID: deviceID, + name: "MetaWear Test", + serialNumber: "SERIAL-1", + firmwareRevision: "1.0.0", + modelNumber: "MMS" + ) + rememberedContext.insert(remembered) + try rememberedContext.save() + + let rememberedDevices = try rememberedContext.fetch(FetchDescriptor()) + #expect(rememberedDevices.map(\.peripheralUUID) == [deviceID]) + + let sessionID = UUID() + let session = MWSessionRecord( + id: sessionID, + deviceID: deviceID, + sensorKind: "float", + startDate: Date(timeIntervalSince1970: 1), + endDate: Date(timeIntervalSince1970: 2), + deviceSerial: "SERIAL-1", + deviceModel: "MMS", + deviceFirmware: "1.0.0", + label: "Temperature" + ) + localContext.insert(session) + try localContext.save() + + let sessions = try localContext.fetch(FetchDescriptor()) + #expect(sessions.map(\.id) == [sessionID]) + } +} diff --git a/Apps/MetaWear/MetaWearTests/ChannelDecimationTests.swift b/Apps/MetaWear/MetaWearTests/ChannelDecimationTests.swift new file mode 100644 index 0000000..2577d79 --- /dev/null +++ b/Apps/MetaWear/MetaWearTests/ChannelDecimationTests.swift @@ -0,0 +1,51 @@ +import Testing +import Foundation +@testable import MetaWearApp + +@Suite("Live-stream channel decimation") +@MainActor +struct ChannelDecimationTests { + + /// A sample whose `f0` equals `i`, so kept points are easy to identify. + private func sample(_ i: Int) -> AnyChartSample { + AnyChartSample(time: Date(timeIntervalSince1970: Double(i)), f0: Float(i), channelCount: 1) + } + + private func channel(hz: Double) -> Channel { + Channel(selection: SensorSelection(id: .accelerometer, hz: hz, range: 2, channel: nil)) + } + + @Test func strideScalesWithConfiguredRate() { + #expect(channel(hz: 200).displayStride == 7) // 200/30 ≈ 6.67 → 7 + #expect(channel(hz: 100).displayStride == 3) // 3.33 → 3 + #expect(channel(hz: 50).displayStride == 2) // 1.67 → 2 + #expect(channel(hz: 25).displayStride == 1) // 0.83 → 1 (no decimation) + #expect(channel(hz: 1).displayStride == 1) // never below 1 + } + + @Test func ingestKeepsFullResButThinsTheDisplaySeries() { + let ch = channel(hz: 100) // stride 3 + for i in 0..<30 { ch.ingest(sample(i)) } + #expect(ch.ring.count == 30) // full resolution preserved (for archive) + #expect(ch.receivedCount == 30) + #expect(ch.displayRing.count == 10) // 1 of every 3 plotted + } + + /// The whole point of the fix: previously-displayed points keep their exact + /// values as new samples arrive (they scroll, they don't get recomputed). + @Test func displayedPointsAreRealAndStable() { + let ch = channel(hz: 100) // stride 3 → keeps the 3rd, 6th, 9th… ingested + for i in 0..<9 { ch.ingest(sample(i)) } + #expect(ch.displayRing.elements.map(\.f0) == [2, 5, 8]) + + for i in 9..<12 { ch.ingest(sample(i)) } // adds i=11 only + // The earlier points are byte-for-byte unchanged; only a new one appended. + #expect(ch.displayRing.elements.map(\.f0) == [2, 5, 8, 11]) + } + + @Test func lowRateSensorKeepsEverySample() { + let ch = channel(hz: 1) // stride 1 + for i in 0..<5 { ch.ingest(sample(i)) } + #expect(ch.displayRing.elements.map(\.f0) == [0, 1, 2, 3, 4]) + } +} diff --git a/README.md b/README.md index c0820dd..dae1075 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,13 @@ This repository contains both the reusable Swift Package products and the MetaWe - Use `MetaWear` when you need the scanner, device actor, BLE transport, protocol layer, and sensor/module APIs. - Add `MetaWearPersistence` when you want SwiftData-backed session storage and CSV export helpers. - Add `MetaWearFirmware` only when your app needs over-the-air DFU firmware updates. -- Open `Apps/MetaWear/MetaWearApp.xcodeproj` for the full MetaWear app. +- Open `Apps/MetaWear/MetaWearApp.xcodeproj` for the full MetaWear app — see [The MetaWear App](#the-metawear-app). ## Table of Contents | Start here | Use it for | |------------|------------| +| [The MetaWear App](#the-metawear-app) | The full SwiftUI app — what it does, running it (incl. Demo Mode), and how it's built | | [Quick Start](#quick-start) | Adding the package, scanning, connecting, streaming, and sending simple commands | | [Architecture](#architecture) | Understanding the scanner/device/protocol/transport layering | | [Supported sensors and modules](#supported-sensors-and-modules) | Finding the Swift type and configuration shape for each MetaWear module | @@ -70,6 +71,51 @@ Hardware integration tests (`MetaWearHardwareTests`) live in `Tests/IntegrationT --- +## The MetaWear App + +`Apps/MetaWear/MetaWearApp.xcodeproj` is the MetaWear app — a SwiftUI app built on +the three SwiftPM products above (`MetaWear` for BLE + protocol, `MetaWearPersistence` +for storage and CSV, `MetaWearFirmware` for DFU). It doubles as the reference +consumer of the SDK: the public APIs an app needs are exercised here end to end. + +### What you can do + +| Area | What it does | +|------|--------------| +| **Scan & connect** | Discover nearby MetaMotion boards with live RSSI, connect, and reconnect to remembered devices | +| **Sensor config** | Choose sensors and per-sensor rate / range (e.g. accelerometer ±2 g @ 100 Hz) before streaming or logging | +| **Live Stream** | Real-time x/y/z charts with live numeric readouts and an effective-Hz indicator, a 3D orientation view driven by sensor-fusion quaternions, pause/resume, and archiving the live buffer to Session History | +| **Logging & download** | Start on-device flash logging — the board keeps recording while disconnected — then reconnect and download; interrupted sessions are recoverable | +| **Session history** | Browse saved sessions, re-plot them, and export any session to CSV (Files / AirDrop / email) | +| **Controls** | Single-shot reads (temperature, pressure, ambient light), plus LED, haptic, and other module actions | +| **Device info & settings** | Battery, signal strength, serial / firmware / model, and per-device settings | + +### Running it + +1. Open `Apps/MetaWear/MetaWearApp.xcodeproj` in Xcode 16+. The app targets **iOS 26** (it uses the Liquid Glass design system); the scheme is **MetaWearApp**. +2. **With hardware** — run on a physical iPhone or iPad and connect a MetaMotion board over Bluetooth. +3. **Without hardware** — run in the iOS Simulator, where **Demo Mode** turns on automatically and injects a fully simulated "Simulated MetaWear" board so every screen works (synthetic live streams, a recordable/downloadable log session, battery / RSSI, …). On a real device, pass the `-MWDemo` launch argument to force Demo Mode. See `App/DemoMode.swift`. + +### How it's built + +- **SwiftUI + `@Observable`**, with `NavigationSplitView` for an iPhone/iPad-adaptive layout and an iOS 26 "Liquid Glass" design system (`Designs/`). +- **`AppStore`** (`App/AppStore.swift`) is the root app state; each screen is driven by a focused `@MainActor` view model in `ViewModels/`. +- **High-rate streaming pipeline** — the BLE consume task appends samples to a non-observed ring buffer; a 33 ms throttle publishes to the UI, and the plotted series is decimated once at ingest, so charts stay smooth and stable at sensor rates up to 200 Hz (`ViewModels/StreamSessionViewModel.swift`, `ViewModels/Channel.swift`). +- **Split persistence** (`Persistence/AppModelContainer.swift`) — sessions, samples, and active log records live in a **local-only** SwiftData store, so high-volume telemetry never enters iCloud; the small remembered-devices list uses a separate **CloudKit-backed** store so boards are recognized across your Apple devices. + +### Where the code lives + +| Path | Contents | +|------|----------| +| `App/` | Entry point, root view, app state (`AppStore`), Demo Mode, shared sample / ring-buffer types | +| `Features/` | One folder per screen: `Scan`, `SensorConfig`, `LiveStream`, `Logging`, `Sessions`, `Controls`, `DeviceInfo`, `Settings`, `DeviceDetail` | +| `ViewModels/` | `@MainActor` view models, roughly one per feature | +| `Designs/` | Liquid Glass components, theme / palette, reusable chips and badges | +| `Persistence/` | SwiftData containers and the app-side `@Model` types | +| `Export/` | CSV exporters for logged and live-buffer sessions | + +--- + ## Development workflow ### Repository layout diff --git a/Sources/MetaWear/Internal/MWLog.swift b/Sources/MetaWear/Internal/MWLog.swift index ab76aef..ab4c63f 100644 --- a/Sources/MetaWear/Internal/MWLog.swift +++ b/Sources/MetaWear/Internal/MWLog.swift @@ -7,3 +7,29 @@ func mwLog(_ message: @autoclosure () -> String) { fputs("\(message())\n", stderr) #endif } + +/// Like `mwLog`, but for the high-frequency per-packet paths (inbound BLE +/// notifications and protocol routing, which fire for every 20-byte packet — +/// including thousands of entries during a log download). Off by default even +/// in DEBUG: writing to stderr on every packet floods the Xcode console, and +/// because that write is synchronous while the debugger is attached it makes +/// streaming and the whole UI feel laggy. Enable when you need wire-level +/// tracing via the `-MWLogVerbose` launch argument or `MW_LOG_VERBOSE=1`. +@inline(__always) +func mwLogVerbose(_ message: @autoclosure () -> String) { +#if DEBUG + if MWVerboseLog.isEnabled { + fputs("\(message())\n", stderr) + } +#endif +} + +#if DEBUG +enum MWVerboseLog { + static let isEnabled: Bool = { + let info = ProcessInfo.processInfo + return info.arguments.contains("-MWLogVerbose") + || info.environment["MW_LOG_VERBOSE"] == "1" + }() +} +#endif diff --git a/Sources/MetaWear/MetaWearScanner.swift b/Sources/MetaWear/MetaWearScanner.swift index 0a1094a..040c8cc 100644 --- a/Sources/MetaWear/MetaWearScanner.swift +++ b/Sources/MetaWear/MetaWearScanner.swift @@ -122,7 +122,7 @@ public final class MetaWearScanner { if let mfg = result.manufacturerData { self.advertisementManufacturerData[id] = mfg } - mwLog("[Scanner] discovered: \(id) name='\(name)'") + mwLogVerbose("[Scanner] discovered: \(id) name='\(name)'") // Accept only MetaWear peripherals (name starts with "MetaWear"). guard name.hasPrefix("MetaWear") else { continue } guard self.discoveredDevices[id] == nil else { continue } diff --git a/Sources/MetaWear/Protocol/MWProtocolLayer.swift b/Sources/MetaWear/Protocol/MWProtocolLayer.swift index 255df98..62df6dd 100644 --- a/Sources/MetaWear/Protocol/MWProtocolLayer.swift +++ b/Sources/MetaWear/Protocol/MWProtocolLayer.swift @@ -342,7 +342,7 @@ actor MWProtocolLayer { let isRead = (registerByte & 0x80) != 0 let normalized = registerByte & 0x3F let key = ModuleRegisterKey(module: moduleId, register: normalized) - mwLog("[Proto] read and route: module=\(String(format: "%02X", moduleId)) register=\(String(format: "%02X", registerByte)) data=\(packet.dropFirst(2).map { String(format: "%02X", $0) })) isRead=\(isRead) [\(packet.map { String(format: "%02X", $0) }.joined(separator: " "))]") + mwLogVerbose("[Proto] read and route: module=\(String(format: "%02X", moduleId)) register=\(String(format: "%02X", registerByte)) data=\(packet.dropFirst(2).map { String(format: "%02X", $0) })) isRead=\(isRead) [\(packet.map { String(format: "%02X", $0) }.joined(separator: " "))]") if isRead { if var waiters = readWaiters[key], !waiters.isEmpty { diff --git a/Sources/MetaWear/Transport/CoreBluetoothPeripheralTransport.swift b/Sources/MetaWear/Transport/CoreBluetoothPeripheralTransport.swift index d09a219..fe6c0de 100644 --- a/Sources/MetaWear/Transport/CoreBluetoothPeripheralTransport.swift +++ b/Sources/MetaWear/Transport/CoreBluetoothPeripheralTransport.swift @@ -371,7 +371,7 @@ extension CoreBluetoothPeripheralTransport: CBPeripheralDelegate { } private func handleValueUpdate(uuid: CBUUID, data: Data) { - mwLog("[BLE] handleValueUpdate: \(uuid) \(data.count) bytes") + mwLogVerbose("[BLE] handleValueUpdate: \(uuid) \(data.count) bytes") if let continuation = readContinuations[uuid] { continuation.resume(returning: data) readContinuations.removeValue(forKey: uuid) diff --git a/Sources/MetaWear/Transport/MWCentralManager.swift b/Sources/MetaWear/Transport/MWCentralManager.swift index 693bb5e..edb83af 100644 --- a/Sources/MetaWear/Transport/MWCentralManager.swift +++ b/Sources/MetaWear/Transport/MWCentralManager.swift @@ -144,7 +144,7 @@ extension MWCentralManager: CBCentralManagerDelegate { let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name let serviceUUIDs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.map { $0.uuidString }.joined(separator: ",") ?? "none" let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data - mwLog("[BLE] didDiscover: \(peripheral.identifier) name=\(name ?? "nil") rssi=\(RSSI) services=[\(serviceUUIDs)] mfg=\(mfgData?.count ?? 0)B") + mwLogVerbose("[BLE] didDiscover: \(peripheral.identifier) name=\(name ?? "nil") rssi=\(RSSI) services=[\(serviceUUIDs)] mfg=\(mfgData?.count ?? 0)B") let result = ScanResult( identifier: peripheral.identifier, name: name, diff --git a/Sources/MetaWearPersistence/MWPersistenceStore.swift b/Sources/MetaWearPersistence/MWPersistenceStore.swift index c299c33..27b55a1 100644 --- a/Sources/MetaWearPersistence/MWPersistenceStore.swift +++ b/Sources/MetaWearPersistence/MWPersistenceStore.swift @@ -129,7 +129,7 @@ public actor MWPersistenceStore { requested: S.persistenceKind ) } - return session.samples + return (session.samples ?? []) .sorted { $0.tickMs < $1.tickMs } .map { r in MWLoggedSample( diff --git a/Sources/MetaWearPersistence/MWSessionSnapshot.swift b/Sources/MetaWearPersistence/MWSessionSnapshot.swift index 6f3e931..e519ee7 100644 --- a/Sources/MetaWearPersistence/MWSessionSnapshot.swift +++ b/Sources/MetaWearPersistence/MWSessionSnapshot.swift @@ -36,7 +36,7 @@ public struct MWSessionSnapshot: Sendable, Identifiable { self.sensorKind = record.sensorKind self.startDate = record.startDate self.endDate = record.endDate - self.sampleCount = record.samples.count + self.sampleCount = record.samples?.count ?? 0 self.deviceSerial = record.deviceSerial self.deviceModel = record.deviceModel self.deviceFirmware = record.deviceFirmware diff --git a/Sources/MetaWearPersistence/Models/MWSampleRecord.swift b/Sources/MetaWearPersistence/Models/MWSampleRecord.swift index beaaf73..35f43e8 100644 --- a/Sources/MetaWearPersistence/Models/MWSampleRecord.swift +++ b/Sources/MetaWearPersistence/Models/MWSampleRecord.swift @@ -20,20 +20,23 @@ import Foundation @Model public final class MWSampleRecord { + // Every stored property is optional or defaulted so this model can back a + // CloudKit-synced store, which requires defaults and forbids non-optional + // properties without one. public var session: MWSessionRecord? /// Wall-clock timestamp. - public var date: Date + public var date: Date = Date.distantPast /// Elapsed milliseconds since the MetaWear last reset. - public var tickMs: Double + public var tickMs: Double = 0 /// Primary value component. - public var f0: Float - public var f1: Float - public var f2: Float - public var f3: Float + public var f0: Float = 0 + public var f1: Float = 0 + public var f2: Float = 0 + public var f3: Float = 0 /// CorrectedCartesianFloat accuracy (0 for all other types). - public var accuracy: UInt8 + public var accuracy: UInt8 = 0 public init( date: Date, diff --git a/Sources/MetaWearPersistence/Models/MWSessionRecord.swift b/Sources/MetaWearPersistence/Models/MWSessionRecord.swift index c2de3af..5b699f2 100644 --- a/Sources/MetaWearPersistence/Models/MWSessionRecord.swift +++ b/Sources/MetaWearPersistence/Models/MWSessionRecord.swift @@ -9,20 +9,24 @@ import Foundation public final class MWSessionRecord { /// Stable, app-assigned session identifier. Use this for all cross-context lookups. - @Attribute(.unique) public var id: UUID + /// + /// Not declared `@Attribute(.unique)`: CloudKit-backed stores do not support + /// unique constraints. Uniqueness is guaranteed by the app always assigning a + /// fresh `UUID` per session, and all lookups go through a UUID predicate. + public var id: UUID = UUID() /// The CoreBluetooth peripheral UUID of the source device. - public var deviceID: UUID + public var deviceID: UUID = UUID() /// Discriminator matching `MWPersistable.persistenceKind` /// (e.g. "cartesian", "quaternion", "euler", "float", …). - public var sensorKind: String + public var sensorKind: String = "" /// Wall-clock timestamp of the first sample. - public var startDate: Date + public var startDate: Date = Date.distantPast /// Wall-clock timestamp of the last sample. - public var endDate: Date + public var endDate: Date = Date.distantPast /// Denormalised device info — stored once per session for offline display. - public var deviceSerial: String - public var deviceModel: String - public var deviceFirmware: String + public var deviceSerial: String = "" + public var deviceModel: String = "" + public var deviceFirmware: String = "" /// User-facing description of the sensor + settings the session /// captured (e.g. "Gyroscope · ±2000 dps · 25 Hz"). Optional because /// `sensorKind` alone is enough to load the samples; this is a display @@ -30,8 +34,11 @@ public final class MWSessionRecord { /// field was added. public var label: String? + /// Optional because CloudKit integration requires every relationship — + /// including to-many — to be optional. Treat `nil` as "no samples"; the + /// store and snapshot accessors coalesce it to an empty array. @Relationship(deleteRule: .cascade, inverse: \MWSampleRecord.session) - public var samples: [MWSampleRecord] + public var samples: [MWSampleRecord]? = [] public init( id: UUID = UUID(),