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(),