From 3821478c1a55216b10eeb544e9dee8ea64e56e85 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 3 Jul 2026 02:02:40 -0600 Subject: [PATCH 1/4] MOB-6: Mob.Motion magnetometer / compass (Elixir + iOS; Android code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- android/jni/mob_beam.h | 3 + android/jni/mob_nif.zig | 58 +++++++++++ decisions/2026-07-02-magnetometer-compass.md | 48 +++++++++ ios/mob_nif.m | 103 +++++++++++++------ lib/mob/motion.ex | 21 +++- 5 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 decisions/2026-07-02-magnetometer-compass.md diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index b0e6c77c..0d9e8e6d 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -100,6 +100,9 @@ void mob_deliver_atom2(jlong pid, const char *a1, const char *a2); void mob_deliver_atom3(jlong pid, const char *a1, const char *a2, const char *a3); void mob_deliver_motion(jlong pid, double ax, double ay, double az, double gx, double gy, double gz, long long ts); +void mob_deliver_motion_mag(jlong pid, double ax, double ay, double az, double gx, double gy, + double gz, double mx, double my, double mz, double heading, + long long ts); void mob_deliver_file_result(jlong pid, const char *event, const char *sub, const char *json_items); void mob_deliver_camera_frame(jlong pid, const unsigned char *bytes, size_t nbytes, int width, int height, const char *format, jlong timestamp_ms, jlong dropped); diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index b55ba622..8947ef2c 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -2272,6 +2272,64 @@ pub export fn mob_deliver_motion( _ = erts.enif_send(null, &pid, env, msg); } +/// Like `mob_deliver_motion` but with the magnetometer field (µT) and a fused +/// heading. `heading < 0` means "unavailable" and is delivered as the atom `nil` +/// (RFC: magnetic north, degrees [0,360)). Emits the 5-key `{:motion, _}` map. +pub export fn mob_deliver_motion_mag( + jpid: jni.JLong, + ax: f64, + ay: f64, + az: f64, + gx: f64, + gy: f64, + gz: f64, + mx: f64, + my: f64, + mz: f64, + heading: f64, + ts: i64, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const accel = erts.makeTuple(env, .{ + erts.enif_make_double(env, ax), + erts.enif_make_double(env, ay), + erts.enif_make_double(env, az), + }); + const gyro = erts.makeTuple(env, .{ + erts.enif_make_double(env, gx), + erts.enif_make_double(env, gy), + erts.enif_make_double(env, gz), + }); + const mag = erts.makeTuple(env, .{ + erts.enif_make_double(env, mx), + erts.enif_make_double(env, my), + erts.enif_make_double(env, mz), + }); + const heading_term = if (heading >= 0.0) + erts.enif_make_double(env, heading) + else + erts.atom(env, "nil"); + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "accel"), + erts.atom(env, "gyro"), + erts.atom(env, "mag"), + erts.atom(env, "heading"), + erts.atom(env, "timestamp"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + accel, + gyro, + mag, + heading_term, + erts.enif_make_int64(env, ts), + }; + const map = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ erts.atom(env, "motion"), map }); + _ = erts.enif_send(null, &pid, env, msg); +} + /// `{:webview, tag, binary}`. When `jpid == 0` the message routes to the /// :mob_screen registered process; otherwise to the explicit pid. fn deliverWebviewBinary(jpid: jni.JLong, comptime tag: [:0]const u8, utf8: [*:0]const u8) void { diff --git a/decisions/2026-07-02-magnetometer-compass.md b/decisions/2026-07-02-magnetometer-compass.md new file mode 100644 index 00000000..9637743b --- /dev/null +++ b/decisions/2026-07-02-magnetometer-compass.md @@ -0,0 +1,48 @@ +# Magnetometer / compass support in Mob.Motion + +- Date: 2026-07-02 +- Status: accepted +- Issue: MOB-6 + +## Context + +`Mob.Motion` exposed accelerometer + gyroscope but not the magnetometer, so a mob +app couldn't build a compass/heading. The sensor is present on most (not all) +phones. This adds it, cross-repo (`mob` Elixir + iOS + zig, `mob_new` Kotlin +template, per-app bridge regen). + +## Decision + +- **Report both `mag` (µT) and a fused `heading`** (degrees), not just the raw + field — a raw vector isn't a usable compass; heading needs sensor fusion, which + the platforms already do. +- **Magnetic north only.** True north needs location + geomagnetic declination — + out of scope; an app can layer it with `Mob.Location`. +- **iOS: opt-in via the sensor list.** When `:magnetometer` is requested and the + device supports the `XMagneticNorthZVertical` attitude reference frame, switch + device motion to that frame (fuses accel+gyro+mag → calibrated `magneticField` + + `heading` on one stream). Otherwise the plain accel/gyro stream is unchanged. +- **Android: register when the hardware is present** (v1), rather than threading + the sensor set through the JNI `motion_start` signature. Android already ignored + the sensor list (both accel+gyro always registered), so this matches existing + behavior; heading comes from `TYPE_ROTATION_VECTOR` → `getRotationMatrixFromVector` + → `getOrientation`. **Follow-up:** make it opt-in (encode the sensor set in the + existing `motion_start` string arg — no ABI change) to avoid running the + magnetometer for accel-only consumers. +- **Delivery via a new `mob_deliver_motion_mag`** (5-key `{:motion, _}` map) rather + than widening `mob_deliver_motion` — keeps the existing accel/gyro path + byte-identical (zero risk to current consumers like the tilt-follow eyes). +- **`heading < 0` ⇒ `nil`.** Both platforms use a negative sentinel for + "unavailable"; the native layer converts it to the atom `nil`. + +## Consequences + +- `mag`/`heading` are **additive** map keys — existing accel/gyro consumers are + unaffected (map patterns aren't exclusive). +- Android v1 runs the magnetometer + rotation-vector whenever the hardware exists, + a small battery cost for accel-only users until the opt-in follow-up lands. +- iOS is opt-in; Android is present-if-hardware. The `heading`/`mag` contract is + identical; only the activation trigger differs (documented in `Mob.Motion`). +- The new delivery function must bind through the generated JNI thunk seam; the + per-app `MobBridge.kt` needs regenerating from the `mob_new` template (the same + bridge-refresh step every native addition needs). diff --git a/ios/mob_nif.m b/ios/mob_nif.m index b5801c14..ad12cffa 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -2963,6 +2963,20 @@ static ERL_NIF_TERM nif_audio_play_at(ErlNifEnv *env, int argc, const ERL_NIF_TE static CMMotionManager *g_motion_manager = nil; static ErlNifPid g_motion_pid; +// True if `name` appears in the Erlang list of sensor-name binaries (argv[0]). +static bool motion_sensor_requested(ErlNifEnv *env, ERL_NIF_TERM list, const char *name) { + ERL_NIF_TERM head, tail = list; + ErlNifBinary bin; + size_t namelen = strlen(name); + while (enif_get_list_cell(env, tail, &head, &tail)) { + if (enif_inspect_binary(env, head, &bin) && bin.size == namelen && + memcmp(bin.data, name, namelen) == 0) { + return true; + } + } + return false; +} + static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; enif_self(env, &pid); @@ -2970,44 +2984,69 @@ static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TER int interval_ms = 100; // argv[0] is a list of sensor name binaries; argv[1] is interval_ms int enif_get_int(env, argv[1], &interval_ms); + bool want_mag = motion_sensor_requested(env, argv[0], "magnetometer"); dispatch_async(dispatch_get_main_queue(), ^{ if (!g_motion_manager) g_motion_manager = [[CMMotionManager alloc] init]; NSTimeInterval interval = interval_ms / 1000.0; g_motion_manager.deviceMotionUpdateInterval = interval; - [g_motion_manager - startDeviceMotionUpdatesToQueue:[NSOperationQueue new] - withHandler:^(CMDeviceMotion *motion, NSError *err) { - if (!motion) - return; - ErlNifPid p = g_motion_pid; - double ax = motion.userAcceleration.x + motion.gravity.x; - double ay = motion.userAcceleration.y + motion.gravity.y; - double az = motion.userAcceleration.z + motion.gravity.z; - double gx = motion.rotationRate.x; - double gy = motion.rotationRate.y; - double gz = motion.rotationRate.z; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM accel = enif_make_tuple3(e, enif_make_double(e, ax), - enif_make_double(e, ay), - enif_make_double(e, az)); - ERL_NIF_TERM gyro = enif_make_tuple3(e, enif_make_double(e, gx), - enif_make_double(e, gy), - enif_make_double(e, gz)); - long long ts = - (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); - ERL_NIF_TERM keys[3] = {enif_make_atom(e, "accel"), - enif_make_atom(e, "gyro"), - enif_make_atom(e, "timestamp")}; - ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e, ts)}; - ERL_NIF_TERM map; - enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = - enif_make_tuple2(e, enif_make_atom(e, "motion"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }]; + + // The magnetic-north reference frame fuses accel+gyro+magnetometer and yields + // a calibrated field + a heading on the same stream — but only if the device + // has a magnetometer. Fall back to the plain accel/gyro stream otherwise. + BOOL magOK = want_mag && ([CMMotionManager availableAttitudeReferenceFrames] & + CMAttitudeReferenceFrameXMagneticNorthZVertical); + + CMDeviceMotionHandler handler = ^(CMDeviceMotion *motion, NSError *err) { + if (!motion) + return; + ErlNifPid p = g_motion_pid; + double ax = motion.userAcceleration.x + motion.gravity.x; + double ay = motion.userAcceleration.y + motion.gravity.y; + double az = motion.userAcceleration.z + motion.gravity.z; + double gx = motion.rotationRate.x; + double gy = motion.rotationRate.y; + double gz = motion.rotationRate.z; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM accel = enif_make_tuple3(e, enif_make_double(e, ax), enif_make_double(e, ay), + enif_make_double(e, az)); + ERL_NIF_TERM gyro = enif_make_tuple3(e, enif_make_double(e, gx), enif_make_double(e, gy), + enif_make_double(e, gz)); + long long ts = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + ERL_NIF_TERM map; + if (magOK) { + // CoreMotion reports the field in µT; heading is degrees [0,360), or -1 unavailable. + CMMagneticField f = motion.magneticField.field; + double hd = motion.heading; + ERL_NIF_TERM mag = enif_make_tuple3(e, enif_make_double(e, f.x), + enif_make_double(e, f.y), enif_make_double(e, f.z)); + ERL_NIF_TERM heading = (hd >= 0.0) ? enif_make_double(e, hd) : enif_make_atom(e, "nil"); + ERL_NIF_TERM keys[5] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), + enif_make_atom(e, "mag"), enif_make_atom(e, "heading"), + enif_make_atom(e, "timestamp")}; + ERL_NIF_TERM vals[5] = {accel, gyro, mag, heading, enif_make_int64(e, ts)}; + enif_make_map_from_arrays(e, keys, vals, 5, &map); + } else { + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), + enif_make_atom(e, "timestamp")}; + ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e, ts)}; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + } + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "motion"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); + }; + + if (magOK) { + [g_motion_manager startDeviceMotionUpdatesUsingReferenceFrame: + CMAttitudeReferenceFrameXMagneticNorthZVertical + toQueue:[NSOperationQueue new] + withHandler:handler]; + } else { + [g_motion_manager startDeviceMotionUpdatesToQueue:[NSOperationQueue new] + withHandler:handler]; + } }); return enif_make_atom(env, "ok"); } diff --git a/lib/mob/motion.ex b/lib/mob/motion.ex index 6bab25d9..8f9530a3 100644 --- a/lib/mob/motion.ex +++ b/lib/mob/motion.ex @@ -1,6 +1,6 @@ defmodule Mob.Motion do @moduledoc """ - Accelerometer and gyroscope sensor data. + Accelerometer, gyroscope, and magnetometer (compass) sensor data. No permission required. @@ -9,21 +9,32 @@ defmodule Mob.Motion do handle_info({:motion, %{ accel: {ax, ay, az}, # m/s² (gravity included) gyro: {gx, gy, gz}, # rad/s + mag: {mx, my, mz}, # µT (microtesla), calibrated — present only when :magnetometer requested + heading: float | nil, # degrees [0, 360) from MAGNETIC north — present only with :magnetometer timestamp: unix_ms }}, socket) - If you only request one sensor, the other tuple will be `{0.0, 0.0, 0.0}`. + `mag` and `heading` appear **only when you request `:magnetometer`** (the plain + accel/gyro stream is unchanged). `heading` is `nil` on a device with no + magnetometer. It's **magnetic** north, not true north — true north needs location + + declination (out of scope; layer it with `Mob.Location`). Magnetometers drift + until calibrated, so prompt the user to wave the phone in a figure-8, and note + that many budget devices ship without one at all. - iOS: `CMMotionManager`. Android: `SensorManager`. + iOS: `CMMotionManager` — device motion with the `XMagneticNorthZVertical` reference + frame when the magnetometer is requested (gives a calibrated field + a fused + heading on the same stream). Android: `SensorManager`. """ - @type sensor :: :accelerometer | :gyro + @type sensor :: :accelerometer | :gyro | :magnetometer @doc """ Start sensor updates. Options: - - `sensors: [:accelerometer] | [:gyro] | [:accelerometer, :gyro]` (default both) + - `sensors:` any subset of `[:accelerometer, :gyro, :magnetometer]` + (default `[:accelerometer, :gyro]`). Add `:magnetometer` for the compass — + the message then also carries `mag` + `heading`. - `interval_ms: integer` — update interval in milliseconds (default `100`) """ @spec start(Mob.Socket.t(), keyword()) :: Mob.Socket.t() From 83fdefcbb2b1fde1e1d8467c0354a37f746bbce1 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 3 Jul 2026 13:41:05 -0600 Subject: [PATCH 2/4] MOB-6: test Mob.Motion opts parsing incl. magnetometer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/mob/motion.ex | 19 +++++++++++++++---- test/mob/motion_test.exs | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 test/mob/motion_test.exs diff --git a/lib/mob/motion.ex b/lib/mob/motion.ex index 8f9530a3..11243133 100644 --- a/lib/mob/motion.ex +++ b/lib/mob/motion.ex @@ -39,13 +39,24 @@ defmodule Mob.Motion do """ @spec start(Mob.Socket.t(), keyword()) :: Mob.Socket.t() def start(socket, opts \\ []) do + {sensors, interval_ms} = parse_opts(opts) + :mob_nif.motion_start(sensors, interval_ms) + socket + end + + @doc false + # The pure kernel of start/2: resolves opts to the `{sensor_strings, interval_ms}` + # the NIF expects, applying defaults. Extracted (public, hidden) so the arg + # building — including that `:magnetometer` survives normalization — is + # unit-testable without a loaded NIF. + @spec parse_opts(keyword()) :: {[String.t()], pos_integer()} + def parse_opts(opts) do sensors = - Keyword.get(opts, :sensors, [:accelerometer, :gyro]) + opts + |> Keyword.get(:sensors, [:accelerometer, :gyro]) |> Enum.map(&Atom.to_string/1) - interval_ms = Keyword.get(opts, :interval_ms, 100) - :mob_nif.motion_start(sensors, interval_ms) - socket + {sensors, Keyword.get(opts, :interval_ms, 100)} end @doc """ diff --git a/test/mob/motion_test.exs b/test/mob/motion_test.exs new file mode 100644 index 00000000..a9d07665 --- /dev/null +++ b/test/mob/motion_test.exs @@ -0,0 +1,29 @@ +defmodule Mob.MotionTest do + use ExUnit.Case, async: true + + # start/2 itself calls into the NIF (unavailable on the host), so we test its + # pure kernel, parse_opts/1 — where the sensor list + interval are resolved. + describe "parse_opts/1" do + test "defaults to accelerometer + gyro at 100ms" do + assert Mob.Motion.parse_opts([]) == {["accelerometer", "gyro"], 100} + end + + test "adds magnetometer when requested (the compass path)" do + assert Mob.Motion.parse_opts(sensors: [:accelerometer, :gyro, :magnetometer]) == + {["accelerometer", "gyro", "magnetometer"], 100} + end + + test "honors a magnetometer-only request" do + assert Mob.Motion.parse_opts(sensors: [:magnetometer]) == {["magnetometer"], 100} + end + + test "honors a custom interval while keeping default sensors" do + assert Mob.Motion.parse_opts(interval_ms: 150) == {["accelerometer", "gyro"], 150} + end + + test "preserves requested sensor order as strings" do + assert Mob.Motion.parse_opts(sensors: [:gyro, :magnetometer, :accelerometer]) == + {["gyro", "magnetometer", "accelerometer"], 100} + end + end +end From 93f423ce6c67e8f0a798482c79394f7fad8944b5 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 4 Jul 2026 13:42:41 -0600 Subject: [PATCH 3/4] MOB-6: make the :magnetometer contract stable + Android opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "" or ",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) --- android/jni/mob_erts.zig | 4 ++ android/jni/mob_nif.zig | 51 +++++++++++--- ...-07-04-magnetometer-stable-key-contract.md | 68 +++++++++++++++++++ ios/mob_nif.m | 23 +++++-- lib/mob/motion.ex | 41 +++++++---- 5 files changed, 158 insertions(+), 29 deletions(-) create mode 100644 decisions/2026-07-04-magnetometer-stable-key-contract.md diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig index fbb371ed..5dae795c 100644 --- a/android/jni/mob_erts.zig +++ b/android/jni/mob_erts.zig @@ -254,6 +254,10 @@ pub extern fn enif_get_int(env: ?*ErlNifEnv, term: ERL_NIF_TERM, ip: *c_int) c_i /// Read a double term. Returns 1 on success, 0 on failure. pub extern fn enif_get_double(env: ?*ErlNifEnv, term: ERL_NIF_TERM, dp: *f64) c_int; +/// Split a list term into its head (car) and tail (cdr). Returns 1 while there +/// is a cell to read, 0 at the empty-list tail — the standard iteration idiom. +pub extern fn enif_get_list_cell(env: ?*ErlNifEnv, list: ERL_NIF_TERM, head: *ERL_NIF_TERM, tail: *ERL_NIF_TERM) c_int; + // ── Convenience wrappers ────────────────────────────────────────────────── // Idiomatic Zig surface over the bare extern fns. Keeps NIF bodies tight. diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 8947ef2c..33522eed 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -2273,8 +2273,11 @@ pub export fn mob_deliver_motion( } /// Like `mob_deliver_motion` but with the magnetometer field (µT) and a fused -/// heading. `heading < 0` means "unavailable" and is delivered as the atom `nil` -/// (RFC: magnetic north, degrees [0,360)). Emits the 5-key `{:motion, _}` map. +/// heading. Sentinels for "no reading": `heading < 0` and a NaN `mag` component +/// are each delivered as the atom `nil` (RFC: magnetic north, degrees [0,360)). +/// This is the delivery used whenever `:magnetometer` was requested — even on a +/// device with no magnetometer, so the `mag`/`heading` keys are always present +/// (as `nil`) rather than absent. Emits the 5-key `{:motion, _}` map. pub export fn mob_deliver_motion_mag( jpid: jni.JLong, ax: f64, @@ -2302,11 +2305,14 @@ pub export fn mob_deliver_motion_mag( erts.enif_make_double(env, gy), erts.enif_make_double(env, gz), }); - const mag = erts.makeTuple(env, .{ - erts.enif_make_double(env, mx), - erts.enif_make_double(env, my), - erts.enif_make_double(env, mz), - }); + const mag = if (std.math.isNan(mx) or std.math.isNan(my) or std.math.isNan(mz)) + erts.atom(env, "nil") + else + erts.makeTuple(env, .{ + erts.enif_make_double(env, mx), + erts.enif_make_double(env, my), + erts.enif_make_double(env, mz), + }); const heading_term = if (heading >= 0.0) erts.enif_make_double(env, heading) else @@ -2739,6 +2745,22 @@ export fn nif_audio_set_volume( return erts.ok(env); } +/// True if `list` (a list of sensor-name binaries) contains `name`. Used to +/// plumb the requested sensor set through to the Kotlin bridge, which otherwise +/// only sees the interval. +fn motionSensorRequested(env: ?*erts.ErlNifEnv, list: erts.ERL_NIF_TERM, name: []const u8) bool { + var cur = list; + var head: erts.ERL_NIF_TERM = undefined; + var tail: erts.ERL_NIF_TERM = undefined; + while (erts.enif_get_list_cell(env, cur, &head, &tail) != 0) { + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, head, &bin) != 0 and + std.mem.eql(u8, bin.data[0..bin.size], name)) return true; + cur = tail; + } + return false; +} + export fn nif_motion_start( env: ?*erts.ErlNifEnv, argc: c_int, @@ -2747,11 +2769,20 @@ export fn nif_motion_start( _ = argc; var interval_ms: c_int = 100; _ = erts.enif_get_int(env, argv[1], &interval_ms); - var ival_buf: [16]u8 = @splat(0); - _ = std.fmt.bufPrint(&ival_buf, "{d}", .{interval_ms}) catch {}; + // Encode the sensor request into the spec string the Kotlin bridge parses: + // "" or ",magnetometer". Android's motion_start only had + // the interval before, so it registered the magnetometer whenever the + // hardware existed — regardless of what the app asked for. Passing the flag + // lets it honor the request (and keep the plain accel/gyro stream plain). + const want_mag = motionSensorRequested(env, argv[0], "magnetometer"); + var spec_buf: [32]u8 = @splat(0); + if (want_mag) + _ = std.fmt.bufPrint(&spec_buf, "{d},magnetometer", .{interval_ms}) catch {} + else + _ = std.fmt.bufPrint(&spec_buf, "{d}", .{interval_ms}) catch {}; var pid: erts.ErlNifPid = undefined; _ = erts.enif_self(env, &pid); - return callBridgePidStr(env, Bridge.motion_start, pid, jni.asCStr(&ival_buf)); + return callBridgePidStr(env, Bridge.motion_start, pid, jni.asCStr(&spec_buf)); } export fn nif_motion_stop( diff --git a/decisions/2026-07-04-magnetometer-stable-key-contract.md b/decisions/2026-07-04-magnetometer-stable-key-contract.md new file mode 100644 index 00000000..1830f438 --- /dev/null +++ b/decisions/2026-07-04-magnetometer-stable-key-contract.md @@ -0,0 +1,68 @@ +# Mob.Motion magnetometer: stable-key contract + Android opt-in + +- Date: 2026-07-04 +- Status: accepted +- Issue: MOB-6 +- Amends: `2026-07-02-magnetometer-compass.md` (supersedes its "Android registers + when hardware present" and "heading nil sentinel only" points) + +## Context + +A review of the first magnetometer cut found the public `Mob.Motion` docstring +made two promises the implementation didn't keep: + +1. "`heading` is `nil` on a device with no magnetometer." False — on both + platforms a magnetometer-less device fell back to the 3-key map, so the + `heading` key was **absent**, not `nil`. A compass app pattern-matching + `%{heading: h}` would hit a `KeyError` on exactly the phones this feature must + degrade gracefully on. +2. "`mag`/`heading` appear only when you request `:magnetometer`." True on iOS + (which parses the sensor list) but false on Android: `motion_start` only ever + received the interval, so it registered the magnetometer whenever the hardware + existed — regardless of the request. An accel/gyro-only consumer (e.g. the + tilt-follow eyes) on a magnetometer phone got surprise 5-key maps plus the + battery cost of two extra sensors. + +The earlier ADR documented the Android asymmetry as an accepted v1 shortcut with +"make it opt-in" as a follow-up. We're doing the follow-up now rather than +shipping an inaccurate public contract. + +## Decision + +Make the map shape a function of **what was requested**, uniformly across +platforms: + +- **Requested `:magnetometer` ⇒ `mag` + `heading` keys are always present**, each + `nil` when there's no reading (no magnetometer hardware, or heading not yet + fused). Stable keys — safe to pattern-match. +- **Did not request ⇒ neither key** (the plain 3-key accel/gyro stream, byte- + identical to before). + +Mechanics: + +- **Android sensor set is now plumbed through** without an ABI change: the + `motion_start/2` NIF still takes `(sensors, interval_ms)`, and `nif_motion_start` + (zig) encodes the request into the existing JNI string arg as + `""` or `",magnetometer"`. Kotlin parses it, registers the + magnetometer + rotation-vector **only when requested**, and picks the delivery + accordingly. +- **`nil` sentinels ride the existing 12-arg delivery.** `mob_deliver_motion_mag` + now maps a NaN `mag` component → `mag: nil` (in addition to the existing + `heading < 0` → `heading: nil`). So "requested but no hardware" still uses the + 5-key delivery, passing NaN/−1, and the app sees `mag: nil, heading: nil`. No + new FFI symbol. +- **iOS** already knew the request (`want_mag`); it now builds the 5-key map + whenever `want_mag`, filling `nil`/`nil` when the magnetic-north reference frame + isn't available, instead of dropping to the 3-key map. + +## Consequences + +- Public contract now matches the docstring on both platforms; the `KeyError` + trap is gone and accel/gyro-only apps are untouched (and pay nothing extra). +- The FFI arity and the accel/gyro-only C path are still byte-identical — the + change is additive (sentinel interpretation + a request flag in a string). +- Device-verified: happy path (real heading/mag) on moto g + iPhone SE; opt-in + (no request ⇒ 3-key, no mag sensors) on a physical device; `nil`/`nil` + requested-but-no-hardware path on the iOS simulator (which has no magnetometer). +- `NaN` is the "no mag reading" wire sentinel — callers never see it (the native + layer converts to `nil`); documented at the `mob_deliver_motion_mag` export. diff --git a/ios/mob_nif.m b/ios/mob_nif.m index ad12cffa..42d1dc0c 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -3015,13 +3015,22 @@ static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TER enif_make_double(e, gz)); long long ts = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); ERL_NIF_TERM map; - if (magOK) { - // CoreMotion reports the field in µT; heading is degrees [0,360), or -1 unavailable. - CMMagneticField f = motion.magneticField.field; - double hd = motion.heading; - ERL_NIF_TERM mag = enif_make_tuple3(e, enif_make_double(e, f.x), - enif_make_double(e, f.y), enif_make_double(e, f.z)); - ERL_NIF_TERM heading = (hd >= 0.0) ? enif_make_double(e, hd) : enif_make_atom(e, "nil"); + if (want_mag) { + // When :magnetometer was requested, always emit the 5-key map so the + // mag/heading keys are a stable contract — nil when there's no reading + // (device has no magnetometer, i.e. !magOK, or heading not yet fused). + ERL_NIF_TERM mag, heading; + if (magOK) { + // CoreMotion reports the field in µT; heading is degrees [0,360), or -1. + CMMagneticField f = motion.magneticField.field; + double hd = motion.heading; + mag = enif_make_tuple3(e, enif_make_double(e, f.x), enif_make_double(e, f.y), + enif_make_double(e, f.z)); + heading = (hd >= 0.0) ? enif_make_double(e, hd) : enif_make_atom(e, "nil"); + } else { + mag = enif_make_atom(e, "nil"); + heading = enif_make_atom(e, "nil"); + } ERL_NIF_TERM keys[5] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), enif_make_atom(e, "mag"), enif_make_atom(e, "heading"), enif_make_atom(e, "timestamp")}; diff --git a/lib/mob/motion.ex b/lib/mob/motion.ex index 11243133..add0a515 100644 --- a/lib/mob/motion.ex +++ b/lib/mob/motion.ex @@ -7,23 +7,40 @@ defmodule Mob.Motion do Updates arrive at `handle_info` at the requested interval: handle_info({:motion, %{ - accel: {ax, ay, az}, # m/s² (gravity included) - gyro: {gx, gy, gz}, # rad/s - mag: {mx, my, mz}, # µT (microtesla), calibrated — present only when :magnetometer requested - heading: float | nil, # degrees [0, 360) from MAGNETIC north — present only with :magnetometer + accel: {ax, ay, az}, # m/s² (gravity included) + gyro: {gx, gy, gz}, # rad/s + mag: {mx, my, mz} | nil, # µT (microtesla), calibrated — key present only with :magnetometer + heading: float | nil, # degrees [0, 360) from MAGNETIC north — key present only with :magnetometer timestamp: unix_ms }}, socket) - `mag` and `heading` appear **only when you request `:magnetometer`** (the plain - accel/gyro stream is unchanged). `heading` is `nil` on a device with no - magnetometer. It's **magnetic** north, not true north — true north needs location - + declination (out of scope; layer it with `Mob.Location`). Magnetometers drift - until calibrated, so prompt the user to wave the phone in a figure-8, and note - that many budget devices ship without one at all. + ## The `:magnetometer` contract + + The `mag` and `heading` keys are present **exactly when you requested + `:magnetometer`** — on both platforms. When you did, they are **always** in the + map, and each is `nil` when there's no reading yet: the device has no + magnetometer at all, or the heading hasn't been fused. So match with `nil`, and + don't assume a value is present: + + case motion do + %{heading: deg} when is_number(deg) -> rotate_needle(deg) + %{heading: nil} -> show_calibration_hint() # no magnetometer, or not yet fused + end + + When you did **not** request `:magnetometer`, the map has neither key (the plain + accel/gyro stream) — so a consumer that never asked for the compass keeps + getting the exact same 3-key map, and pays no extra sensor/battery cost. + + It's **magnetic** north, not true north — true north needs location + declination + (out of scope; layer it with `Mob.Location`). Magnetometers drift until + calibrated, so prompt the user to wave the phone in a figure-8, and note that + many budget devices ship without one at all (there, `heading`/`mag` stay `nil`). iOS: `CMMotionManager` — device motion with the `XMagneticNorthZVertical` reference - frame when the magnetometer is requested (gives a calibrated field + a fused - heading on the same stream). Android: `SensorManager`. + frame when the magnetometer is requested and available (a calibrated field + a + fused heading on one stream); `nil`/`nil` when requested on a device without one. + Android: `SensorManager` — magnetometer + rotation-vector, registered only when + `:magnetometer` is requested. """ @type sensor :: :accelerometer | :gyro | :magnetometer From c59471387b542486e01aec4c92a791cc4e4fdb0b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 4 Jul 2026 14:58:16 -0600 Subject: [PATCH 4/4] MOB-6: mark 2026-07-02 ADR partially superseded 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) --- decisions/2026-07-02-magnetometer-compass.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/decisions/2026-07-02-magnetometer-compass.md b/decisions/2026-07-02-magnetometer-compass.md index 9637743b..c757c3f5 100644 --- a/decisions/2026-07-02-magnetometer-compass.md +++ b/decisions/2026-07-02-magnetometer-compass.md @@ -1,7 +1,7 @@ # Magnetometer / compass support in Mob.Motion - Date: 2026-07-02 -- Status: accepted +- Status: accepted; partially superseded by `2026-07-04-magnetometer-stable-key-contract.md` (the "Android registers whenever hardware present (v1)" activation trigger and the map-shape/`nil`-key contract — the magnetic-north scope, additive keys, and delivery-via-`mob_deliver_motion_mag` decisions still stand) - Issue: MOB-6 ## Context