Skip to content

MetaWear app: App Store readiness (v10.0) + firmware update, persistence & performance work#7

Merged
lkasso merged 15 commits into
mainfrom
appstore-readiness
Jun 20, 2026
Merged

MetaWear app: App Store readiness (v10.0) + firmware update, persistence & performance work#7
lkasso merged 15 commits into
mainfrom
appstore-readiness

Conversation

@lkasso

@lkasso lkasso commented Jun 20, 2026

Copy link
Copy Markdown
Member

Gets the next-generation MetaWear iOS app (Apps/MetaWear/) ready to ship as an update (v10.0) to the existing App Store record (Apple ID 1547334547, bundle com.mbientlab.MetaWear), plus the fixes and features found along the way.

Note: the App Store submission collateral lives in a git-ignored local Apps/MetaWear/AppStore/ folder and is intentionally not part of this PR.

App Store readiness (v10.0)

  • CFBundleShortVersionString = 10.0, ITSAppUsesNonExemptEncryption = false, UIRequiredDeviceCapabilities = [bluetooth-le].
  • Reused the existing brand app icon (single universal 1024×1024, no alpha).
  • Added PrivacyInfo.xcprivacy (no tracking, no collected data types).
  • Standardized the iOS deployment target (iOS 26 floor — required by the Liquid Glass UI).
  • Kept UIBackgroundModes = [remote-notification] + aps-environment solely for background CloudKit sync of remembered devices (documented in the review notes).

Persistence / CloudKit

  • Fixed a launch crash — CloudKit requires optional relationships (MWSessionRecord.samples).
  • Split SwiftData containers: RememberedDevice → CloudKit private DB; sessions/samples/log records → local-only (cloudKitDatabase: .none). High-volume telemetry never enters iCloud — syncing thousands of sample rows thrashed the main actor and bloated iCloud storage.
  • CloudKit-unavailable fallback reopens the same on-disk store (same config name) instead of orphaning synced data.

Performance

  • Live chart: decimate at ingest into a stable display ring (fixes both the lag and the "older values keep changing" instability) + a little spacing between the readout and the graph.
  • Gated per-packet BLE/proto logging behind an opt-in -MWLogVerbose flag (the log flood was a real source of lag).

Features

  • Firmware update in Settings (Nordic DFU): shows the installed revision, checks MbientLab's catalog, runs an OTA flash with a live DFUProgress readout, and reconnects afterward. Idle-guarded; gated to real hardware so Demo Mode never hits the live catalog. (Untested on hardware — validate via TestFlight.)
  • Controls → Quick Reads: data-driven and gated by the board's discovered modules, so one-shot reads (temperature / pressure / ambient light) only appear when the sensor is present.

Other

  • README: a proper section on the MetaWear app.
  • Review fixes: archive-to-history on Stop, ambient-light stream teardown leak.
  • Tests: AppModelContainerTests (container separation), ChannelDecimationTests.

🤖 Generated with Claude Code

lkasso and others added 15 commits June 14, 2026 12:50
Make Apps/MetaWear submittable as an update to the existing App Store record (id1547334547):

- App icon: reuse the existing MetaWear marketing icon (1024, no alpha)
- Privacy manifest: add PrivacyInfo.xcprivacy (no tracking/collection; first-party required-reason audit was clean)
- Info.plist: version 10.0, ITSAppUsesNonExemptEncryption=false, UIRequiredDeviceCapabilities=[bluetooth-le], UIBackgroundModes=[remote-notification]
- Deployment target: standardize on iOS 26.0 (Liquid Glass floor)
- CloudKit: enable private-DB sync (cloudKitDatabase .automatic); make all @model types CloudKit-compatible (drop @Attribute(.unique), add defaults); add aps-environment entitlement
- Add App Store collateral drafts under Apps/MetaWear/AppStore/

Verified: SDK + 42 persistence tests pass; app builds for iOS 26.0 simulator and bundle validates; app unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CloudKit integration requires every relationship — including to-many — to be optional, not just defaulted. The non-optional `samples` relationship crashed the app at container load (NSCocoaErrorDomain 134060: 'all relationships be optional'). Make it [MWSampleRecord]? and coalesce nil to [] in the store and snapshot accessors.

Verified: app launches on the simulator, the CloudKit-backed store loads and default.store is created; 42 persistence tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NSPersistentCloudKitContainer on the app's main ModelContext caused continuous WAL checkpoints and failed background-task scheduling (com.apple.coredata.cloudkit.activity.export, BGSystemTaskSchedulerErrorDomain Code=3), starving the @mainactor view models. Single sensor reads, the Live-Stream Add-sensor menu, and logging start all stalled, while raw BLE streaming (which never touches SwiftData) kept working — the tell. CloudKit sync is not required for v1.

Set cloudKitDatabase: .none. Models stay CloudKit-compatible (optional relationships, no .unique, defaults) so it can be re-enabled later with proper setup: register BGTaskSchedulerPermittedIdentifiers for com.apple.coredata.cloudkit.activity, route writes through a dedicated background context, and deploy the schema to Production.

Verified: app launches with zero CloudKit-activity / WAL-checkpoint churn in the console; local store loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-packet debug logs fire dozens of times/sec while streaming, for every BLE advertisement while scanning, and thousands of times during a log download. Each is a synchronous fputs to stderr; with the Xcode debugger attached that write is slow, so DEBUG builds feel laggy across streaming, scanning, and download. (Release was already unaffected: mwLog is #if DEBUG.)

Add mwLogVerbose — off by default even in DEBUG, enabled via -MWLogVerbose or MW_LOG_VERBOSE=1 — and move the four hot-path sites (proto read-and-route, BLE handleValueUpdate, central didDiscover, scanner discovered) onto it. Lifecycle logs (connect, scan start/stop, startLogging, command read/write) stay on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live chart plotted the full 600-sample ring per channel, and SwiftUI Charts redrew every LineMark on each 33 ms throttle tick. Charts can't sustain 600 x N marks at 30 fps, so as the ring filled each redraw blew past the frame budget and the visible trace fell progressively behind the live data — the 'values take a long time to show up on the graph' symptom.

Decimate the plotted series to <=180 points (uniform integer index mapping; first and last preserved). A live card is only a few hundred px wide, so the trace is visually identical while Charts' per-tick work drops ~3x. The full-resolution ring is untouched: archive-to-history still saves every sample, and the numeric readout still shows the true latest value. Adds DownsampleChartTests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chart card stacked header -> x/y/z readout -> graph with one uniform VStack spacing, leaving the live numbers cramped against the top of the graph. Add extra top padding to just the chart so the readout has room, without changing the header spacing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous per-frame downsample re-picked points from the whole ring every 33 ms, so as new samples arrived the index mapping shifted and every plotted point — including old ones — changed value: the trace looked like it was going crazy.

Decimate once, at ingest. Channel.ingest stores every sample full-resolution in `ring` (for archive / latest / true-rate readout) and mirrors 1-of-`displayStride` into a capped `displayRing` that is only ever appended to. The throttle snapshots `displayRing`, so plotted points are real samples that scroll FIFO and are never recomputed — smooth and stable. Stride scales with the configured rate (~30 plotted Hz; e.g. 200 Hz accel → stride 7); low-rate sensors keep every sample.

Replaces DownsampleChartTests with ChannelDecimationTests (stride scaling, full-res preservation, and the stability property).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The README was SDK-only; the app was three one-line mentions. Add a 'The MetaWear App' section (features by screen, how to run it incl. Demo Mode, how it's built, and a code map), a Table of Contents entry, and a link from the intro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single SwiftData container with two. A CloudKit-backed store holds only RememberedDevice, so the small paired-board list syncs across the user's devices via private iCloud (with a local fallback if CloudKit can't initialize, so a sync hiccup never blocks launch). A separate local-only store owns the high-volume sessions/samples/logs, which must never enter iCloud.

This resolves the earlier CloudKit-thrash problem: cross-device board recognition without syncing thousands of sample rows per session. Adds AppModelContainerTests verifying the two stores are distinct and that RememberedDevice vs MWSessionRecord land in their respective containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Apps/MetaWear/AppStore/ drafts (store listing, review notes, privacy policy, screenshots, submission checklist) are internal submission material. Stripped from history and git-ignored so they stay local-only and never reach the remote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-light leak

From the pre-submission review (all low-severity, verified):

- A1: StreamSessionViewModel.stop() now archives the captured buffers to Session History (idempotent via hasArchived), so Stop-then-background no longer silently loses the session.

- A3: the RememberedDevices CloudKit fallback reuses the same ModelConfiguration name with cloudKitDatabase: .none, so a failed-CloudKit launch reopens the same store file instead of orphaning synced devices in a divergent SQLite file.

- A5: ControlsViewModel.readAmbientLight() tears the stream down on both success and error paths, so a mid-read failure no longer leaves the LTR329 enabled and self-blocking subsequent reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressure and Ambient Light reads were shown unconditionally. MetaMotion R/RL and S carry no barometer or ambient-light sensor, so those reads timed out / failed. ControlsViewModel.loadModules() now reads the device's discovered module set, and the view shows the Pressure / Ambient Light rows only when the board reports those modules present — matching the gating in SensorConfigView / LogSessionView (isPresent + SensorKey.module). Temperature (NRF die, present on every board) is unchanged.

Also folds in a PrivacyInfo.xcprivacy comment update describing the split-container privacy story (no change to declared keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render Quick Reads from a presence-driven spec list instead of hardcoded rows: each one-shot-readable sensor (temperature, pressure, ambient light) appears only when its module is discovered present, so a board shows exactly the reads it supports — and adding another one-shot read is a one-line spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A new Firmware section in Device Settings surfaces the board's installed
revision, checks MbientLab's release catalog for a newer build, and runs an
over-the-air DFU update with a live progress readout.

FirmwareUpdateViewModel wraps the MetaWearFirmware API:
- checkForFirmwareUpdate() -> Up to date / Update available
- updateFirmwareToLatest() streamed into a Phase enum the section renders
- idle guard up front so a busy board fails before the bootloader handoff
- only a .completed event counts as success (an empty stream = already latest)
- reconnects through AppStore afterwards to refresh the now-stale deviceInfo

The section is gated to real hardware (device.identifier !=
DemoBLETransport.deviceIdentifier), so Demo Mode never hits the live catalog
with a synthetic firmware revision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the placeholder board into a more hardware-accurate MetaMotion-style
model: a rounded outer shell with an inset front-panel highlight, LED/button
discs, and a USB edge detail, built from reusable addRoundedBox / addDisc /
material helpers. Preserve the entity's own scale during the orientation
animation (was forcing scale .one) and refresh the loader scale/offsets and
the doc comments for the still-optional real USDZ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lkasso lkasso merged commit 3ebadba into main Jun 20, 2026
5 checks passed
@lkasso lkasso deleted the appstore-readiness branch June 20, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant