react-native-nitro-compass 1.0.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +166 -20
  2. package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +718 -105
  3. package/ios/HybridNitroCompass.swift +119 -6
  4. package/lib/commonjs/hook.js +102 -11
  5. package/lib/commonjs/hook.js.map +1 -1
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/multiplex.js +23 -2
  8. package/lib/commonjs/multiplex.js.map +1 -1
  9. package/lib/module/hook.js +103 -12
  10. package/lib/module/hook.js.map +1 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/multiplex.js +23 -2
  13. package/lib/module/multiplex.js.map +1 -1
  14. package/lib/typescript/src/hook.d.ts +49 -1
  15. package/lib/typescript/src/hook.d.ts.map +1 -1
  16. package/lib/typescript/src/index.d.ts +2 -2
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/multiplex.d.ts.map +1 -1
  19. package/lib/typescript/src/specs/NitroCompass.nitro.d.ts +158 -18
  20. package/lib/typescript/src/specs/NitroCompass.nitro.d.ts.map +1 -1
  21. package/nitrogen/generated/android/c++/JCompassSample.hpp +7 -3
  22. package/nitrogen/generated/android/c++/JDebugInfo.hpp +85 -0
  23. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.cpp +21 -0
  24. package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +4 -0
  25. package/nitrogen/generated/android/c++/JSensorKind.hpp +6 -3
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/CompassSample.kt +9 -4
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/DebugInfo.kt +86 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/HybridNitroCompassSpec.kt +16 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/SensorKind.kt +4 -3
  30. package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Bridge.hpp +12 -0
  31. package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Umbrella.hpp +3 -0
  32. package/nitrogen/generated/ios/c++/HybridNitroCompassSpecSwift.hpp +29 -0
  33. package/nitrogen/generated/ios/swift/CompassSample.swift +7 -2
  34. package/nitrogen/generated/ios/swift/DebugInfo.swift +64 -0
  35. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec.swift +4 -0
  36. package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +45 -0
  37. package/nitrogen/generated/ios/swift/SensorKind.swift +8 -4
  38. package/nitrogen/generated/shared/c++/CompassSample.hpp +6 -2
  39. package/nitrogen/generated/shared/c++/DebugInfo.hpp +111 -0
  40. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.cpp +4 -0
  41. package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +7 -0
  42. package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
  43. package/package.json +2 -2
  44. package/src/hook.ts +161 -12
  45. package/src/index.ts +2 -0
  46. package/src/multiplex.ts +23 -2
  47. package/src/specs/NitroCompass.nitro.ts +164 -18
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  Fast, accurate compass heading for React Native, powered by [Nitro Modules](https://github.com/mrousavy/nitro).
4
4
 
5
- - **Android**: `Sensor.TYPE_ROTATION_VECTOR` (gyro + accel + magnetometer sensor fusion), with `TYPE_GEOMAGNETIC_ROTATION_VECTOR` fallback for gyroless devices. Sensor delivery on a dedicated `HandlerThread` never blocks the UI thread. Heading accuracy taken from `event.values[4]` of the rotation vector.
6
- - **iOS**: `CLLocationManager` heading via `CLHeading.magneticHeading`. Apple's stack already handles sensor fusion natively.
7
- - **JS API**: type-safe Nitro callbacks — no `NativeEventEmitter`, no string event names.
5
+ - **Android**: raw `TYPE_MAGNETIC_FIELD_UNCALIBRATED` + `TYPE_ACCELEROMETER` fed through `SensorManager.getRotationMatrix()` + `getOrientation()`, with a `TYPE_GAME_ROTATION_VECTOR` complementary filter on top for steady-state smoothness. This path is **stateless** — when a magnet or laptop is removed, the very next sample produces the correct heading instead of waiting for OS-level fusion to re-converge. We also detect OS hard-iron-bias jumps as a separate interference signal, so weak magnet events that don't push field magnitude out-of-band still register. Sensor delivery on a dedicated `HandlerThread` never blocks the UI thread.
6
+ - **iOS**: `CLLocationManager` heading via `CLHeading.magneticHeading` for direction; `CMDeviceMotion.magneticField` (calibrated) for field strength + interference detection. Apple's stack handles sensor fusion natively.
7
+ - **JS API**: type-safe Nitro callbacks — no `NativeEventEmitter`, no string event names. Optional `useCompass()` React hook bundles subscription lifecycle, calibration / interference observation, and live-tuneable knobs into one ergonomic call.
8
8
 
9
9
  ## Why
10
10
 
11
- Most React Native compass libraries use the legacy `accelerometer + magnetometer + getRotationMatrix` Android approach, which is laggy, noisy, and requires a figure-8 calibration on every session. This library uses Android's modern fused rotation-vector sensor (recommended by Google since 2013), giving you stable headings without calibration on virtually any modern device.
11
+ Most React Native compass libraries use Android's `TYPE_ROTATION_VECTOR`, which feels great until you put a magnet, a phone, or a laptop next to the device — then the OS-level Kalman filter holds a poisoned bias estimate for many seconds after the source is removed. This library computes heading directly from raw `accelerometer + magnetometer` via `getRotationMatrix()` (the same approach used by popular consumer compass apps), so recovery from interference is instant. We trade a few degrees of steady-state jitter for stateless behaviour, then add back smoothness via two layers: an adaptive input-side low-pass on the accel and mag vectors, plus a `TYPE_GAME_ROTATION_VECTOR` (gyro+accel) complementary filter that integrates Δyaw between events and lets mag samples pull it back to absolute. The end result tracks fast turns without lag, ignores transient magnet events, and snaps back instantly when interference clears.
12
12
 
13
13
  ## Requirements
14
14
 
@@ -52,32 +52,68 @@ NitroCompass.isStarted(): boolean
52
52
  NitroCompass.hasCompass(): boolean
53
53
 
54
54
  NitroCompass.setFilter(degrees: number): void
55
+ NitroCompass.setSmoothing(alpha: number): void
56
+ NitroCompass.setDeclination(degrees: number): void
57
+ NitroCompass.setLocation(latitude: number, longitude: number): void
58
+ NitroCompass.setPauseOnBackground(enabled: boolean): void
59
+
55
60
  NitroCompass.getCurrentHeading(): CompassSample | undefined
56
61
  NitroCompass.getDiagnostics(): SensorDiagnostics | undefined
57
- NitroCompass.setDeclination(degrees: number): void
62
+ NitroCompass.getDebugInfo(): DebugInfo
63
+
58
64
  NitroCompass.setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) => void): void
59
65
  NitroCompass.setOnInterferenceDetected(onChange: (interferenceDetected: boolean) => void): void
60
- NitroCompass.setPauseOnBackground(enabled: boolean): void
66
+
67
+ NitroCompass.recalibrate(): void
68
+ NitroCompass.getPermissionStatus(): PermissionStatus
69
+ NitroCompass.requestPermission(): Promise<PermissionStatus>
61
70
 
62
71
  interface CompassSample {
63
- heading: number // degrees, [0, 360); magnetic by default, true-north if setDeclination was called
64
- accuracy: number // degrees, smaller is better; -1 if unknown
72
+ heading: number // degrees, [0, 360); magnetic by default, true-north if setDeclination was called
73
+ accuracy: number // degrees, smaller is better; -1 if unknown
74
+ fieldStrengthMicroTesla: number // µT magnitude of the local magnetic field; -1 until first reading
65
75
  }
66
76
 
67
77
  type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
68
- type SensorKind = 'rotationVector' | 'geomagneticRotationVector' | 'coreLocation'
78
+ type PermissionStatus = 'granted' | 'denied' | 'unknown'
79
+ type SensorKind =
80
+ | 'magnetometer'
81
+ | 'coreLocation'
82
+ | 'rotationVector' // legacy, no longer returned
83
+ | 'geomagneticRotationVector' // legacy, no longer returned
69
84
  interface SensorDiagnostics { sensor: SensorKind }
85
+ interface DebugInfo {
86
+ interferenceActive: boolean
87
+ msSinceLastBiasJump: number // -1 if never seen / iOS
88
+ expectedFieldMicroTesla: number // -1 if setLocation not called
89
+ lastFieldMicroTesla: number // -1 if no reading
90
+ fusedYawDeg: number // NaN before first sample / iOS
91
+ lastYawRateDegPerS: number // 0 if game-RV unavailable
92
+ hasGameRotationVector: boolean // false on iOS
93
+ usingUncalibratedMag: boolean // false on iOS
94
+ }
70
95
  ```
71
96
 
72
97
  - `filterDegrees` — minimum change between successive samples before the next one is delivered. Pass `0` for "every event"; typical UI values are `1`–`3`. Use `setFilter()` to change live without tearing down the subscription.
98
+ - `setSmoothing(alpha)` — low-pass smoothing factor (EMA α) applied to heading samples on Android. Range `(0, 1]`, default `0.2` (~100 ms time constant at 50 Hz). `1.0` disables smoothing; smaller values smooth more (kills jitter, adds a touch of latency). **No-op on iOS** — `CLLocationManager` filters internally with Apple's own algorithm, so layering an EMA on top would only add latency. See [Smoothing](#smoothing) below.
73
99
  - `start()` is idempotent in the destructive sense — calling it while already started silently replaces the previous subscription with the new callback. `stop()` is idempotent and safe from inside the `onHeading` callback.
74
- - `getDiagnostics()` reports which sensor would produce headings on this device useful for explaining quality differences (e.g. a phone falling back to `geomagneticRotationVector` will be more susceptible to magnetic interference than one using `rotationVector`). Safe to call before `start()`.
75
- - `accuracy` is a numeric uncertainty (degrees). On iOS it comes from `CLHeading.headingAccuracy` directly. On Android it comes from `event.values[4]` of the rotation-vector sensor; if the sensor stack does not publish that (rare), the module falls back to a coarse degree estimate from `SensorManager.SENSOR_STATUS_*` (`HIGH→5°`, `MEDIUM→15°`, `LOW→30°`).
100
+ - `getDiagnostics()` reports which sensor would produce headings on this device. On Android this is always `magnetometer` for current builds (older versions returned `rotationVector` / `geomagneticRotationVector`); on iOS it's `coreLocation`. Safe to call before `start()`.
101
+ - `accuracy` is a numeric uncertainty (degrees). On iOS it comes from `CLHeading.headingAccuracy` directly. On Android it's a coarse degree estimate derived from the magnetometer's `SensorManager.SENSOR_STATUS_*` accuracy bucket Android's figure-8 calibration signal mapped to `HIGH→5°`, `MEDIUM→15°`, `LOW→30°`.
102
+ - `fieldStrengthMicroTesla` is the magnitude of the local magnetic field in µT, or `-1` until the first reading lands. Earth's field is normally 25–65 µT — values well outside this band signal external interference (laptops, monitors, magnets, ferrous metal). Useful for rendering a field-strength meter à la consumer compass apps.
76
103
  - `getCurrentHeading()` returns the most recently emitted sample (with declination already applied), or `undefined` if not started yet or no sample has arrived.
104
+ - `setLocation(lat, lon)` lets the library tighten the interference detection band on Android. With a valid location, the generic 20–70 µT "Earth field" band is replaced by `expectedField ± 15 µT`, where `expectedField` comes from the WMM2025 model bundled in `GeomagneticField`. This catches weak interference at high/low latitudes where Earth's natural field is near or above 60 µT. Pass `NaN` or out-of-range values to revert to the generic band. **No-op on iOS** — `CLLocationManager` already uses GPS-derived location internally.
105
+ - `recalibrate()` is a manual nudge for stuck calibration state. On Android it re-registers the sensor listeners (often nudges the magnetometer driver to re-evaluate soft/hard-iron calibration); on iOS it dismisses the system heading-calibration overlay and stops/restarts heading updates. Idempotent; safe to call before `start()`. The user still has to move the device through varying orientations — this just clears cached state so progress is reflected promptly. Useful behind a "Refresh" button in your calibration UI.
106
+ - `getDebugInfo()` returns a live snapshot of internal pipeline state. Intended for diagnosing user-reported issues — not needed for normal operation. The bundled `<DebugPanel />` in [example/components/DebugPanel.tsx](./example/components/DebugPanel.tsx) polls it at 4 Hz behind a collapsible footer; copy/adapt it into your debug build to make user bug reports self-diagnosing. Most fields are Android-only — see the inline JSDoc on the `DebugInfo` interface.
107
+ - `getPermissionStatus()` / `requestPermission()` map to platform location permission. Always `'granted'` on Android (sensors require no permission). On iOS, `getPermissionStatus()` reads `CLLocationManager.authorizationStatus`; `requestPermission()` prompts the system "Allow location" dialog if status is `'unknown'` and resolves once the user makes a choice. iOS does not re-prompt — subsequent calls resolve immediately with the cached status.
77
108
 
78
109
  ### Calibration
79
110
 
80
- `setOnCalibrationNeeded(cb)` registers a callback fired whenever the calibration bucket transitions. Buckets are derived from numeric accuracy on both platforms using the same thresholds, so values agree across iOS and Android: `<5°` → `'high'`, `<15°` → `'medium'`, `<30°` → `'low'`, otherwise `'unreliable'`. On iOS, the system's "wave the device in a figure-8" prompt is suppressed and reported to your callback as `'unreliable'` — show your own UI when you receive that bucket.
111
+ `setOnCalibrationNeeded(cb)` registers a callback fired whenever the calibration bucket transitions. Each platform's bucket is derived from its **native** accuracy semantics, since the underlying values are not directly comparable:
112
+
113
+ - **iOS** uses `CLHeading.headingAccuracy` (degrees). Apple is conservative — even well-calibrated iPhones typically report `10–15°` and rarely below `5°` (per [Apple staff on the developer forums](https://developer.apple.com/forums/thread/79687)). Buckets: `<20°` → `'high'`, `<35°` → `'medium'`, `<55°` → `'low'`, otherwise `'unreliable'`. The system's "wave the device in a figure-8" prompt is suppressed and reported to your callback as `'unreliable'` — show your own UI when you receive that bucket.
114
+ - **Android** uses the magnetometer's `SensorManager.SENSOR_STATUS_*` bucket from `onAccuracyChanged` directly (`HIGH` / `MEDIUM` / `LOW` / `UNRELIABLE`) — Android's signal that the user should do (or has done) a figure-8 to recalibrate. **When magnetic interference is currently detected, the surfaced bucket is downgraded by one notch** (`HIGH→MEDIUM`, `MEDIUM→LOW`, `LOW→UNRELIABLE`) — calibration ("the magnetometer needs to be tuned") and interference ("the field is currently being skewed by something nearby") are independent signals, and surfacing `quality='high'` alongside `interfering=true` is contradictory UX.
115
+
116
+ Both platforms can plausibly emit `'high'` on a clean device — the threshold split just reflects each OS's reporting style.
81
117
 
82
118
  ```ts
83
119
  NitroCompass.setOnCalibrationNeeded((q) => {
@@ -87,7 +123,9 @@ NitroCompass.setOnCalibrationNeeded((q) => {
87
123
 
88
124
  ### Magnetic interference
89
125
 
90
- `setOnInterferenceDetected(cb)` fires `true` when the raw magnetic field magnitude leaves the normal Earth band (~20–70 µT) and `false` when it returns. Typical sources are laptops, monitors, car engines, and large steel structures — these can skew heading by tens of degrees while the calibration bucket still reads `'medium'` or better, so this is complementary to the calibration callback rather than a replacement.
126
+ `setOnInterferenceDetected(cb)` fires `true` when the raw magnetic field magnitude leaves the normal Earth band (~20–70 µT) and `false` when it returns. Typical sources are laptops, monitors, car engines, and large steel structures — these can skew heading by tens of degrees.
127
+
128
+ Interference is surfaced three ways: (1) directly via this callback, (2) on Android, the calibration bucket emitted by `setOnCalibrationNeeded` is downgraded by one notch while interference is detected (see the Calibration section above), and (3) every `CompassSample` carries `fieldStrengthMicroTesla` so you can render a live strength meter. On iOS, the calibration downgrade is skipped — `CLLocationManager`'s own accuracy reporting already responds to magnetometer disturbance, so a separate downgrade would double-count.
91
129
 
92
130
  ```ts
93
131
  NitroCompass.setOnInterferenceDetected((interfering) => {
@@ -96,7 +134,94 @@ NitroCompass.setOnInterferenceDetected((interfering) => {
96
134
  })
97
135
  ```
98
136
 
99
- Android uses `Sensor.TYPE_MAGNETIC_FIELD` at ~5 Hz. iOS uses `CMDeviceMotion.magneticField` (calibrated, with the device's own hard-iron bias subtracted in real time) note that no transitions are reported on iOS until CoreMotion's bias estimate converges, typically a second or two of normal device movement. Only triggered while `start()` is active; no debounce, so brief excursions still fire.
137
+ Detection on **Android** combines two signals: (1) the raw magnetic field magnitude leaving the Earth band, and (2) recent OS hard-iron-bias jumps on `TYPE_MAGNETIC_FIELD_UNCALIBRATED`. The bias-jump signal catches *weak* interference events the magnitude check alone would miss — e.g. another phone placed on top of yours, where the corrected field magnitude stays near 50 µT but the OS still revises its bias estimate. Either signal flips `interfering` to `true`; both must clear (and a 1.5 s grace window expire) before `false` is reported. If you call `setLocation(lat, lon)`, the magnitude band tightens to `expectedField ± 15 µT` for a more sensitive gate at high or low latitudes.
138
+
139
+ On **iOS**, detection uses `CMDeviceMotion.magneticField` (calibrated, with the device's own hard-iron bias subtracted in real time). Transitions wait for CoreMotion's bias estimate to converge (5 consecutive non-`uncalibrated` samples — typically a second or two of normal device movement after subscribe) so the first second post-`start()` doesn't fire false positives.
140
+
141
+ Only triggered while `start()` is active; no debounce, so brief excursions still fire.
142
+
143
+ ### Location-aware interference (recipe)
144
+
145
+ `setLocation(lat, lon)` tightens the Android interference gate from the generic 20–70 µT band to `expectedField ± 15 µT`, where `expectedField` comes from the WMM2025 model bundled in `GeomagneticField`. This catches weak interference at high or low latitudes where Earth's natural field is near or above 60 µT — exactly the cases where the generic band is too loose to detect, say, another phone placed nearby.
146
+
147
+ Pair it with any geolocation library — the example below uses [`react-native-geolocation-service`](https://github.com/Agontuk/react-native-geolocation-service):
148
+
149
+ ```tsx
150
+ import { useEffect } from 'react'
151
+ import { Platform, PermissionsAndroid } from 'react-native'
152
+ import Geolocation from 'react-native-geolocation-service'
153
+ import { useCompass } from 'react-native-nitro-compass'
154
+
155
+ async function ensureLocationPermission(): Promise<boolean> {
156
+ if (Platform.OS === 'android') {
157
+ const granted = await PermissionsAndroid.request(
158
+ PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
159
+ )
160
+ return granted === PermissionsAndroid.RESULTS.GRANTED
161
+ }
162
+ // iOS — useCompass already prompts for location auth for the compass
163
+ // itself, so a granted permission lets us read position too.
164
+ return true
165
+ }
166
+
167
+ function CompassScreen() {
168
+ const compass = useCompass({ enabled: true })
169
+ const { setLocation } = compass
170
+
171
+ useEffect(() => {
172
+ let cancelled = false
173
+ let watchId: number | undefined
174
+
175
+ void (async () => {
176
+ if (!(await ensureLocationPermission()) || cancelled) return
177
+
178
+ // One-shot fix at start. Coarse accuracy is fine — Earth's
179
+ // field varies < 0.5 % per km, so a city-block-resolution
180
+ // position is more than enough for the ±15 µT tolerance.
181
+ Geolocation.getCurrentPosition(
182
+ ({ coords }) => {
183
+ if (!cancelled) setLocation(coords.latitude, coords.longitude)
184
+ },
185
+ () => {
186
+ /* swallow — falls back to the generic 20–70 µT band */
187
+ },
188
+ { enableHighAccuracy: false, timeout: 15_000, maximumAge: 60_000 },
189
+ )
190
+
191
+ // Optional: keep `expectedField` in sync if the user moves
192
+ // long-distance. A 50 km move shifts the WMM-derived field by
193
+ // ~0.3 µT — well inside the tolerance — so a coarse 10 km /
194
+ // 10 minute filter is plenty.
195
+ watchId = Geolocation.watchPosition(
196
+ ({ coords }) => {
197
+ if (!cancelled) setLocation(coords.latitude, coords.longitude)
198
+ },
199
+ () => {},
200
+ {
201
+ enableHighAccuracy: false,
202
+ distanceFilter: 10_000,
203
+ interval: 10 * 60 * 1000,
204
+ },
205
+ )
206
+ })()
207
+
208
+ return () => {
209
+ cancelled = true
210
+ if (watchId !== undefined) Geolocation.clearWatch(watchId)
211
+ }
212
+ }, [setLocation])
213
+
214
+ // …render compass.reading, compass.quality, etc.
215
+ }
216
+ ```
217
+
218
+ A few notes:
219
+
220
+ - **`setLocation` from `useCompass()` has a stable identity**, so listing it in the effect dependency array is safe — it won't re-run on every compass tick.
221
+ - **No location permission required** for the compass itself on Android (sensors are unrestricted). The location permission requested above is purely so `react-native-geolocation-service` can give you a fix; if it's denied, the compass still works — it just falls back to the generic interference band.
222
+ - **iOS is a no-op** for `setLocation` — `CLLocationManager` already uses GPS-derived location internally for all field-related reasoning, so calling it changes nothing on iOS. The recipe still works cross-platform; it's just that on iOS the call is effectively wasted. You can guard with `if (Platform.OS === 'android')` if you'd rather skip the geolocation request entirely on iOS.
223
+ - **One-shot vs watch**: if your app is stationary (typical phone use), the one-shot `getCurrentPosition` is enough. The `watchPosition` only matters if your user is driving / flying long distances; the field strength changes slowly enough that a coarse low-frequency watch is fine.
224
+ - **Pass `NaN` to revert** to the generic band if the location becomes stale or the user revokes permission: `setLocation(NaN, NaN)`.
100
225
 
101
226
  ### Magnetic vs true north
102
227
 
@@ -116,6 +241,20 @@ NitroCompass.setDeclination(declination)
116
241
 
117
242
  Pass `0` to revert to magnetic. Declination survives `stop()`/`start()` cycles.
118
243
 
244
+ ### Smoothing
245
+
246
+ Android's raw accelerometer + magnetometer heading jitters by `±1–3°` even at rest. iOS's `CLLocationManager` filters internally; Android does not. The library applies a circular EMA low-pass filter on `(sin θ, cos θ)` (handles `359°→0°` wraparound cleanly) before delivering samples, with `α = 0.2` by default — the same value used in [phishman3579/android-compass](https://github.com/phishman3579/android-compass/blob/master/src/com/jwetherell/compass/common/LowPassFilter.java) and within the range used by [Trail Sense](https://github.com/kylecorry31/Trail-Sense)'s production compass code.
247
+
248
+ Tune live:
249
+
250
+ ```ts
251
+ NitroCompass.setSmoothing(0.2) // default — kills jitter, ~100 ms latency
252
+ NitroCompass.setSmoothing(0.4) // snappier, more visible jitter
253
+ NitroCompass.setSmoothing(1.0) // disabled — every sample passes through
254
+ ```
255
+
256
+ `setSmoothing` is a no-op on iOS — Apple's stack already filters heading internally, so layering an EMA on top would only add latency without removing noise.
257
+
119
258
  ### Background pause
120
259
 
121
260
  By default the underlying sensor / location-manager subscription is silently paused while the app is backgrounded and resumed when it returns to the foreground; the JS callback and any declination set via `setDeclination` are preserved across the pause. To opt out (e.g. for a fitness tracker that needs heading while screen-off):
@@ -134,6 +273,7 @@ import { useCompass } from 'react-native-nitro-compass'
134
273
  function CompassView() {
135
274
  const { reading, quality, interfering, hasCompass } = useCompass({
136
275
  filterDegrees: 1,
276
+ smoothingAlpha: 0.2,
137
277
  declination: 0,
138
278
  pauseOnBackground: true,
139
279
  enabled: true,
@@ -161,21 +301,27 @@ function useCompass(options?: UseCompassOptions): UseCompassResult
161
301
  | Option | Type | Default | Description |
162
302
  | --- | --- | --- | --- |
163
303
  | `filterDegrees` | `number` | `1` | Minimum change between successive samples in degrees. Pass `0` for "every event". Updated live via `NitroCompass.setFilter()` whenever the prop changes. |
304
+ | `smoothingAlpha` | `number` | `0.2` | Low-pass smoothing factor (EMA α) on Android. `1.0` disables smoothing; smaller values smooth more. No-op on iOS. See [Smoothing](#smoothing). |
164
305
  | `declination` | `number` | `0` | Magnetic-to-true offset in signed degrees. Pull from a model like [`geomagnetism`](https://github.com/kahirokunn/geomagnetism) keyed on the user's lat/lon. When non-zero, every emitted sample is true-north. |
165
306
  | `pauseOnBackground` | `boolean` | `true` | Pause the underlying sensor / location-manager subscription while the app is backgrounded and resume on foreground. |
166
307
  | `enabled` | `boolean` | `true` | Toggle the heading subscription without unmounting. When `false`, `reading` stops updating but calibration and interference observation continue (so you can still show warnings). |
167
308
 
168
- `filterDegrees`, `declination`, and `pauseOnBackground` map to global state on `NitroCompass` — if multiple hooks set them, last-write-wins.
309
+ `filterDegrees`, `smoothingAlpha`, `declination`, and `pauseOnBackground` map to global state on `NitroCompass` — if multiple hooks set them, last-write-wins.
169
310
 
170
311
  #### Result
171
312
 
172
313
  | Field | Type | Description |
173
314
  | --- | --- | --- |
174
- | `reading` | `CompassSample \| null` | Latest emitted sample (`{ heading, accuracy }`), or `null` until the first arrives. Heading is true-north when `declination` is set, magnetic otherwise. |
315
+ | `reading` | `CompassSample \| null` | Latest emitted sample (`{ heading, accuracy, fieldStrengthMicroTesla }`), or `null` until the first arrives. Heading is true-north when `declination` is set, magnetic otherwise. |
175
316
  | `quality` | `AccuracyQuality \| null` | Coarse calibration bucket — `'high'`, `'medium'`, `'low'`, or `'unreliable'`. `null` until the first transition. Show your own calibration UI on `'unreliable'`. |
176
- | `interfering` | `boolean` | `true` while the raw magnetic field magnitude is outside the normal Earth band (~20–70 µT) laptops, monitors, car engines, steel structures. |
317
+ | `interfering` | `boolean` | `true` while external magnetic interference is detected (field-magnitude band check + Android bias-jump grace). See [Magnetic interference](#magnetic-interference). |
177
318
  | `hasCompass` | `boolean` | Hardware availability — read once on first render. Render a fallback when `false`. |
178
- | `diagnostics` | `SensorDiagnostics \| undefined` | Which sensor backs the readings on this device (`rotationVector`, `geomagneticRotationVector`, or `coreLocation`). Useful for explaining quality differences. |
319
+ | `diagnostics` | `SensorDiagnostics \| undefined` | Which sensor backs the readings on this device (`magnetometer` on Android, `coreLocation` on iOS). Useful for explaining quality differences. |
320
+ | `permission` | `PermissionStatus` | Latest platform permission state. Always `'granted'` on Android. On iOS may transition `'unknown'` → `'granted'`/`'denied'` after `requestPermission()` resolves. |
321
+ | `getCurrentHeading` | `() => CompassSample \| undefined` | Synchronous read of the most recent sample. Stable identity across renders. Useful inside event handlers without forcing a re-render. |
322
+ | `recalibrate` | `() => void` | Force a best-effort sensor recalibration (Refresh button behind a calibration banner). Stable identity. See [`NitroCompass.recalibrate()`](#api). |
323
+ | `setLocation` | `(lat: number, lon: number) => void` | Tighten the interference gate using `expectedField ± 15 µT` (Android only). No-op on iOS. Stable identity. |
324
+ | `requestPermission` | `() => Promise<PermissionStatus>` | Prompt the platform permission dialog (iOS) and update the hook's `permission` field. Resolves with the resulting status. Stable identity. |
179
325
 
180
326
  For non-React state managers, lower-level `addHeadingListener(cb): () => void`, `addCalibrationListener(cb): () => void`, and `addInterferenceListener(cb): () => void` are also exported. They are reference-counted: the first heading listener calls `start()`, the last unsubscribe calls `stop()`. Mixing these helpers with direct `NitroCompass.start()` / `setOnCalibrationNeeded()` / `setOnInterferenceDetected()` will clobber the multiplex's internal callback slot — pick one path.
181
327
 
@@ -211,7 +357,7 @@ The same pattern is used in [example/components/Compass.tsx](./example/component
211
357
  ## Permissions
212
358
 
213
359
  - **iOS**: requires `NSLocationWhenInUseUsageDescription` in `Info.plist`. `CLLocationManager` only emits headings when location permission is granted.
214
- - **Android**: no permission required for the rotation-vector sensor.
360
+ - **Android**: no permission required for the magnetometer or accelerometer.
215
361
 
216
362
  ## Example app
217
363
 
@@ -250,7 +396,7 @@ The example imports `NitroCompass` directly from the workspace `src/` (via Metro
250
396
 
251
397
  ## Acknowledgments
252
398
 
253
- The Android rotation-vector pattern (sensor fusion, surface-rotation remapping, `getOrientation` extraction) is adapted from the MIT-licensed [Andromeda](https://github.com/kylecorry31/andromeda) sensor library by Kyle Corry, which powers the [Trail Sense](https://github.com/kylecorry31/Trail-Sense) wilderness navigation app.
399
+ The Android sensor pattern (raw mag + accel fusion via `getRotationMatrix`, surface-rotation remapping, `getOrientation` extraction, EMA on `(sin θ, cos θ)`) is adapted from the MIT-licensed [Andromeda](https://github.com/kylecorry31/andromeda) sensor library by Kyle Corry, which powers the [Trail Sense](https://github.com/kylecorry31/Trail-Sense) wilderness navigation app.
254
400
 
255
401
  Bootstrapped with [create-nitro-module](https://github.com/patrickkabwe/create-nitro-module).
256
402