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_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 b55ba622..33522eed 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -2272,6 +2272,70 @@ 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. 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, + 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 = 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 + 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 { @@ -2681,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, @@ -2689,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-02-magnetometer-compass.md b/decisions/2026-07-02-magnetometer-compass.md new file mode 100644 index 00000000..c757c3f5 --- /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; 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 + +`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/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 b5801c14..42d1dc0c 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,78 @@ 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 (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")}; + 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..add0a515 100644 --- a/lib/mob/motion.ex +++ b/lib/mob/motion.ex @@ -1,40 +1,79 @@ defmodule Mob.Motion do @moduledoc """ - Accelerometer and gyroscope sensor data. + Accelerometer, gyroscope, and magnetometer (compass) sensor data. No permission required. 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 + 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) - If you only request one sensor, the other tuple will be `{0.0, 0.0, 0.0}`. + ## The `:magnetometer` contract - iOS: `CMMotionManager`. Android: `SensorManager`. + 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 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 + @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() 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