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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ AGENTS.md
.vscode/
.idea/
*.swp

# Local-only App Store submission collateral — never pushed
Apps/MetaWear/AppStore/
12 changes: 11 additions & 1 deletion Apps/MetaWear/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>10.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MetaWear needs Bluetooth to connect to and stream from your sensor.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>bluetooth-le</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
Expand Down
2 changes: 2 additions & 0 deletions Apps/MetaWear/MetaWear.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.mbientlab.MetaWear</string>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
65 changes: 45 additions & 20 deletions Apps/MetaWear/MetaWear/Features/Controls/ControlsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -100,6 +91,7 @@ struct ControlsView: View {
if viewModel == nil {
viewModel = ControlsViewModel(device: device)
}
await viewModel?.loadModules()
}
.alert(item: Binding(
get: { viewModel?.lastError },
Expand All @@ -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
Expand Down
129 changes: 87 additions & 42 deletions Apps/MetaWear/MetaWear/Features/LiveStream/QuaternionRealityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<Float>,
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<Float>,
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading