Skip to content

MOB-6: magnetometer / compass support in Mob.Motion#59

Merged
GenericJam merged 4 commits into
masterfrom
MOB-6-motion-magnetometer
Jul 4, 2026
Merged

MOB-6: magnetometer / compass support in Mob.Motion#59
GenericJam merged 4 commits into
masterfrom
MOB-6-motion-magnetometer

Conversation

@GenericJam

@GenericJam GenericJam commented Jul 3, 2026

Copy link
Copy Markdown
Owner

What

Adds magnetometer + fused heading (compass) support to Mob.Motion, so a mob app can build a compass. Cross-repo: this is the mob half (Elixir + iOS + zig). The Kotlin template half is GenericJam/mob_new#26.

Linear: MOB-6

Changes

  • lib/mob/motion.ex — new :magnetometer sensor in the sensor list; documents the additive mag (µT) + heading (degrees, magnetic north) map keys.
  • iOS (ios/mob_nif.m) — when :magnetometer is requested and the device supports CMAttitudeReferenceFrameXMagneticNorthZVertical, switch device motion to that reference frame (fuses accel+gyro+mag → calibrated field + heading on one stream). Emits a 5-key {:motion, _} map. heading < 0 ⇒ atom nil.
  • Android (android/jni/mob_nif.zig, mob_beam.h) — new mob_deliver_motion_mag export builds the 5-key map through the JNI thunk seam.
  • decisions/2026-07-02-magnetometer-compass.md — ADR (magnetic-north-only scope, additive keys, per-platform activation trigger).

Design notes

  • mag + heading are additive map keys — existing accel/gyro consumers (e.g. the tilt-follow googly eyes) are byte-identical and unaffected, because the delivery goes through a new mob_deliver_motion_mag rather than widening mob_deliver_motion.
  • Magnetic north only. True north needs location + declination — out of scope; an app can layer Mob.Location.
  • iOS is opt-in via the sensor list; Android registers when the hardware is present (v1). Follow-up to make Android opt-in (encode the sensor set in the existing motion_start string arg, no ABI change) is noted in the ADR.

Verification

  • Host: mix test + mix format + mix credo --strict green; pre-push hook passed.
  • On-hardware verify is still pending — the moto g 2021 has a magnetometer but mob_test is a pre-C→zig-migration fossil with no build.zig, so it can't produce a current native build. Needs a current app on a magnetometer device to close the loop; flagged in the ADR as follow-up.

🤖 Generated with Claude Code

Add :magnetometer to Mob.Motion — the {:motion, _} map gains `mag` (µT) + a fused
`heading` (deg from magnetic north; nil when unavailable). See
decisions/2026-07-02-magnetometer-compass.md and COMPATIBILITY notes.

- Elixir (motion.ex): :magnetometer sensor + the mag/heading contract + docs.
- iOS (mob_nif.m): parse the sensor list; when :magnetometer is requested +
  available, use the XMagneticNorthZVertical reference frame → calibrated field +
  heading on the device-motion stream. Plain accel/gyro path unchanged.
- Android (zig mob_deliver_motion_mag + mob_beam.h prototype): new 5-key delivery
  fn; the Kotlin half is in the mob_new template (paired PR). Keeps the existing
  mob_deliver_motion path byte-identical.

VERIFY STATUS: Elixir + iOS host-checked; the Android Kotlin compiled cleanly on a
real device build. Full zig/link + on-hardware compass read is NOT yet verified —
the only wired test app (mob_test) is a pre-Mix→zig-migration fossil (no build.zig)
that can't build against current mob, unrelated to this change. Verify on a current
app (fresh `mix mob.new` or migrated mob_test) with a magnetometer device
(moto g 2021 has one) before release. Android magnetometer is registered whenever
the hardware is present (v1); opt-in threading is a noted follow-up in the ADR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
Owner Author

✅ Device-verified on real hardware

Verified end-to-end on a moto g power (2021) (magnetometer mmc5603x + rotation-vector present). Instead of the pre-zig mob_test fossil, I generated a fresh app (mix mob.new --local wired to both PR worktrees) so it built from these exact templates + mob lib, deployed native to the device, and read the live screen assigns over Erlang dist:

screen:  MagTest.HomeScreen
heading: 197.9                  # fused compass heading (magnetic north)
mag:     {22.9, -102.7, 26.5}   # µT, calibrated
accel:   {-0.1, 8.6, 4.9}       # existing accel/gyro path intact (~9.8 magnitude)
count:   395 → 400 in ~1s       # live stream at requested interval

On-screen: 198° → cardinal "S" (internally consistent). Full chain proven: MobBridge.motion_start registers magnetometer + rotation-vector → nativeDeliverMotionMagbeam_jni.c thunk → mob_deliver_motion_mag (zig) → BEAM {:motion, %{mag, heading}} → screen. The earlier on-hardware-verify caveat is now cleared.

@GenericJam

Copy link
Copy Markdown
Owner Author

✅ Now also iOS device-verified — physical iPhone SE (3rd gen)

Built native to the physical iPhone (mix mob.new compass_test --ios --local against the same worktrees), connected over WiFi dist, read the live screen:

screen:  CompassTest.HomeScreen
heading: 156.8                   # fused heading (CMMotionManager XMagneticNorthZVertical frame)
mag:     {-0.65, -46.5, -48.3}   # µT calibrated (|mag| ~67, plausible indoors)
count:   252 → 261 in ~1.2s      # live stream

Both platforms now proven end-to-end for the mag + heading path (Android moto g + iOS iPhone SE).

Unrelated aside: --blank iOS apps currently fail to link _mob_register_plugins (a stale contract between the mob_new AppDelegate template and mob_dev's bootstrap-gen guard) — filed as MOB-7, worked around here by generating with showcase plugins active. Not a magnetometer issue.

GenericJam and others added 2 commits July 3, 2026 13:41
The feature shipped without any Mob.Motion test (the module had none).
Extract start/2's pure kernel as parse_opts/1 — resolves the sensor list +
interval, applying defaults — so it's unit-testable without a loaded NIF,
then cover the default, magnetometer, magnetometer-only, custom-interval, and
order-preservation cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review found the docstring promised two things the code didn't do:
1. "heading is nil without a magnetometer" — actually the key was ABSENT
   (3-key fallback), a KeyError trap for exactly the compass app this enables.
2. "mag/heading only when you request :magnetometer" — true on iOS, but Android
   never received the sensor list, so it registered the magnetometer whenever
   the hardware existed regardless of the request (surprise 5-key maps + battery
   cost for accel/gyro-only consumers like the tilt-follow eyes).

Fix — make the map shape a function of the request, uniformly:
- Requested :magnetometer => mag + heading keys ALWAYS present, each nil when
  there's no reading (no hardware, or heading not yet fused). Stable to match on.
- Not requested => plain 3-key accel/gyro stream, byte-identical to before.

Mechanics (no FFI arity change):
- Android sensor set is plumbed through the existing JNI string: nif_motion_start
  encodes "<interval>" or "<interval>,magnetometer"; Kotlin registers the
  magnetometer + rotation-vector only when requested (mob_new PR).
- mob_deliver_motion_mag maps a NaN mag component -> mag: nil (alongside the
  existing heading < 0 -> nil), so "requested but no hardware" rides the 5-key
  delivery with sentinels. Adds enif_get_list_cell to scan the sensor list.
- iOS builds the 5-key map whenever want_mag, filling nil/nil when the
  magnetic-north reference frame isn't available instead of dropping to 3-key.

Device-verified all three paths: real values (moto g + iPhone SE), opt-in 3-key
(moto g + iPhone SE), and nil/nil graceful degradation on a no-magnetometer
Android emulator. Decision: decisions/2026-07-04-magnetometer-stable-key-contract.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
Owner Author

Addressed both review findings — with a code fix, not just docs

Thanks for the careful read; both were real. Rather than soften the docstring, the contract is now what the docstring promised, verified on device.

New contract (both platforms): when you request :magnetometer, the mag and heading keys are always present (each nil when there's no reading); when you don't, the map is the plain 3-key accel/gyro stream, byte-identical to before.

Issue 1 (heading absent → KeyError trap): fixed. When :magnetometer is requested on a device with no magnetometer, we now emit the 5-key map with mag: nil, heading: nil instead of falling back to 3-key. So %{heading: nil} -> hint fires instead of a KeyError. iOS builds the 5-key map whenever want_mag (nil/nil when the magnetic-north frame is unavailable); Android rides the existing 12-arg delivery, with mob_deliver_motion_mag mapping a NaN mag component → nil (alongside the existing heading < 0 → nil).

Issue 2 (Android ignored the request): fixed in code, as you preferred. nif_motion_start now encodes the sensor set into the existing JNI string ("<interval>" / "<interval>,magnetometer" — no ABI change), and Kotlin registers the magnetometer + rotation-vector only when requested. So the tilt-follow-eyes case keeps its exact 3-key map and pays nothing extra.

Device-verified all three paths (assigns read over dist):

  • Requested + hardware → real heading/mag, 5-key: moto g + iPhone SE
  • Not requested → plain 3-key, keys absent (opt-in): moto g + iPhone SE
  • Requested + no magnetometer → heading: nil, mag: nil, keys present: no-magnetometer Android emulator (keys: [:accel, :gyro, :heading, :mag, :timestamp], heading nil, mag nil, stream flowing)

Docstring rewritten to state the stable contract + show the nil-safe match. ADR: decisions/2026-07-04-magnetometer-stable-key-contract.md (amends the earlier one's Android-registration point).

The 2026-07-04 stable-key-contract ADR changed the Android activation
trigger (now opt-in) and the map-shape/nil-key contract. Flag the old
ADR's Status so a reader landing there isn't misled by the now-stale
'Android registers whenever hardware present (v1)' decision, while
keeping the parts still in force (magnetic-north scope, additive keys,
delivery via mob_deliver_motion_mag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GenericJam GenericJam merged commit f9556c5 into master Jul 4, 2026
4 checks passed
GenericJam added a commit that referenced this pull request Jul 4, 2026
Ships MOB-6 (#59): request :magnetometer for mag + fused heading, with a
stable per-request key contract (keys present exactly when requested, nil
when no reading) identical across iOS and Android. Device-verified on
moto g + iPhone SE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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