MOB-6: magnetometer / compass support in Mob.Motion#59
Conversation
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>
✅ Device-verified on real hardwareVerified end-to-end on a moto g power (2021) (magnetometer On-screen: 198° → cardinal "S" (internally consistent). Full chain proven: |
✅ Now also iOS device-verified — physical iPhone SE (3rd gen)Built native to the physical iPhone ( Both platforms now proven end-to-end for the Unrelated aside: |
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>
Addressed both review findings — with a code fix, not just docsThanks 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 Issue 1 (heading absent → KeyError trap): fixed. When Issue 2 (Android ignored the request): fixed in code, as you preferred. Device-verified all three paths (assigns read over dist):
Docstring rewritten to state the stable contract + show the |
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>
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>
What
Adds magnetometer + fused heading (compass) support to
Mob.Motion, so a mob app can build a compass. Cross-repo: this is themobhalf (Elixir + iOS + zig). The Kotlin template half is GenericJam/mob_new#26.Linear: MOB-6
Changes
lib/mob/motion.ex— new:magnetometersensor in the sensor list; documents the additivemag(µT) +heading(degrees, magnetic north) map keys.ios/mob_nif.m) — when:magnetometeris requested and the device supportsCMAttitudeReferenceFrameXMagneticNorthZVertical, 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⇒ atomnil.android/jni/mob_nif.zig,mob_beam.h) — newmob_deliver_motion_magexport 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+headingare 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 newmob_deliver_motion_magrather than wideningmob_deliver_motion.Mob.Location.motion_startstring arg, no ABI change) is noted in the ADR.Verification
mix test+mix format+mix credo --strictgreen; pre-push hook passed.mob_testis a pre-C→zig-migration fossil with nobuild.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