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.
- package/README.md +166 -20
- package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +718 -105
- package/ios/HybridNitroCompass.swift +119 -6
- package/lib/commonjs/hook.js +102 -11
- package/lib/commonjs/hook.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/multiplex.js +23 -2
- package/lib/commonjs/multiplex.js.map +1 -1
- package/lib/module/hook.js +103 -12
- package/lib/module/hook.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/multiplex.js +23 -2
- package/lib/module/multiplex.js.map +1 -1
- package/lib/typescript/src/hook.d.ts +49 -1
- package/lib/typescript/src/hook.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/multiplex.d.ts.map +1 -1
- package/lib/typescript/src/specs/NitroCompass.nitro.d.ts +158 -18
- package/lib/typescript/src/specs/NitroCompass.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JCompassSample.hpp +7 -3
- package/nitrogen/generated/android/c++/JDebugInfo.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +4 -0
- package/nitrogen/generated/android/c++/JSensorKind.hpp +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/CompassSample.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/DebugInfo.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/HybridNitroCompassSpec.kt +16 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrocompass/SensorKind.kt +4 -3
- package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Bridge.hpp +12 -0
- package/nitrogen/generated/ios/NitroCompass-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridNitroCompassSpecSwift.hpp +29 -0
- package/nitrogen/generated/ios/swift/CompassSample.swift +7 -2
- package/nitrogen/generated/ios/swift/DebugInfo.swift +64 -0
- package/nitrogen/generated/ios/swift/HybridNitroCompassSpec.swift +4 -0
- package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +45 -0
- package/nitrogen/generated/ios/swift/SensorKind.swift +8 -4
- package/nitrogen/generated/shared/c++/CompassSample.hpp +6 -2
- package/nitrogen/generated/shared/c++/DebugInfo.hpp +111 -0
- package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.cpp +4 -0
- package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +7 -0
- package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
- package/package.json +2 -2
- package/src/hook.ts +161 -12
- package/src/index.ts +2 -0
- package/src/multiplex.ts +23 -2
- package/src/specs/NitroCompass.nitro.ts +164 -18
|
@@ -4,6 +4,7 @@ import android.app.Activity
|
|
|
4
4
|
import android.app.ActivityManager
|
|
5
5
|
import android.app.Application
|
|
6
6
|
import android.content.Context
|
|
7
|
+
import android.hardware.GeomagneticField
|
|
7
8
|
import android.hardware.Sensor
|
|
8
9
|
import android.hardware.SensorEvent
|
|
9
10
|
import android.hardware.SensorEventListener
|
|
@@ -29,24 +30,29 @@ import kotlin.math.sqrt
|
|
|
29
30
|
/**
|
|
30
31
|
* Android implementation of NitroCompass.
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Computes heading directly from raw `TYPE_ACCELEROMETER` +
|
|
34
|
+
* `TYPE_MAGNETIC_FIELD` via `SensorManager.getRotationMatrix()` /
|
|
35
|
+
* `getOrientation()`. This path is *stateless*: the moment external
|
|
36
|
+
* interference (a magnet, a laptop, etc.) is removed, the very next
|
|
37
|
+
* magnetometer sample produces the correct heading.
|
|
36
38
|
*
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
+
* The fused `TYPE_ROTATION_VECTOR` sensor would be smoother in steady
|
|
40
|
+
* state, but its OS-level Kalman filter can hold a poisoned bias estimate
|
|
41
|
+
* for many seconds after a strong field excursion — a recovery delay
|
|
42
|
+
* that's the dominant complaint vs. consumer compass apps. Smoothing the
|
|
43
|
+
* raw fix instead, via the existing EMA on (sin θ, cos θ), restores
|
|
44
|
+
* the steady-state feel without the recovery penalty.
|
|
39
45
|
*/
|
|
40
46
|
@DoNotStrip
|
|
41
47
|
@Keep
|
|
42
48
|
class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
43
49
|
|
|
44
50
|
companion object {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
51
|
+
// Sensor.TYPE_ACCELEROMETER + TYPE_MAGNETIC_FIELD at SENSOR_DELAY_GAME
|
|
52
|
+
// both fire ~50Hz. If neither has produced an event in 1.5s while
|
|
53
|
+
// the app is foregrounded, assume the sensor stack froze and force
|
|
54
|
+
// a re-registration — same watchdog policy as the previous
|
|
55
|
+
// rotation-vector implementation.
|
|
50
56
|
private const val WATCHDOG_PERIOD_MS = 1_500L
|
|
51
57
|
private const val STALE_THRESHOLD_NS = 1_500_000_000L
|
|
52
58
|
|
|
@@ -56,11 +62,75 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
56
62
|
// structural steel routinely push readings well above 100 µT.
|
|
57
63
|
private const val EARTH_FIELD_MIN_UT = 20.0
|
|
58
64
|
private const val EARTH_FIELD_MAX_UT = 70.0
|
|
65
|
+
|
|
66
|
+
// Default low-pass smoothing for the heading. The raw mag/accel
|
|
67
|
+
// computation produces a usable but noisier signal than the OS's
|
|
68
|
+
// gyro-fused rotation vector, so the dial visibly jitters by 1–3°
|
|
69
|
+
// at rest. Smoothing (sin θ, cos θ) instead of θ avoids 359°→0°
|
|
70
|
+
// wraparound artifacts. α=0.2 gives a ~5-sample time constant — at
|
|
71
|
+
// SENSOR_DELAY_GAME (~20ms) that's ~100ms of lag, imperceptible
|
|
72
|
+
// compared to the noise it removes. Tunable live via setSmoothing().
|
|
73
|
+
private const val DEFAULT_SMOOTHING_ALPHA = 0.2
|
|
74
|
+
|
|
75
|
+
// Input low-pass on the accel and mag *vectors* before they enter
|
|
76
|
+
// getRotationMatrix(). Killing the noise at its source matters more
|
|
77
|
+
// than smoothing the output heading, because rotation-matrix math
|
|
78
|
+
// amplifies small input noise non-linearly.
|
|
79
|
+
//
|
|
80
|
+
// Per FSensor / Trail-Sense conventions we use *different* α per
|
|
81
|
+
// sensor — accel is jerk-noisy (high-frequency), mag is hard-iron-
|
|
82
|
+
// drift-noisy (low-frequency). And α is *adaptive* on the gyro-
|
|
83
|
+
// derived yaw rate: stronger filter when still (kills steady-state
|
|
84
|
+
// jitter), weaker filter on fast turns (avoids visible lag).
|
|
85
|
+
private const val INPUT_FILTER_ALPHA_ACCEL_STILL = 0.05f
|
|
86
|
+
private const val INPUT_FILTER_ALPHA_ACCEL_FAST = 0.25f
|
|
87
|
+
private const val INPUT_FILTER_ALPHA_MAG_STILL = 0.10f
|
|
88
|
+
private const val INPUT_FILTER_ALPHA_MAG_FAST = 0.40f
|
|
89
|
+
private const val YAW_RATE_STILL_DEG_S = 5.0
|
|
90
|
+
private const val YAW_RATE_FAST_DEG_S = 30.0
|
|
91
|
+
|
|
92
|
+
// Complementary filter blend on yaw: each magnetometer sample pulls
|
|
93
|
+
// the gyro-integrated `fusedYawDeg` toward the mag-derived yaw by
|
|
94
|
+
// this fraction. α=0.02 at 50Hz → time constant ≈ 1 s. Small enough
|
|
95
|
+
// that mag noise is averaged out; large enough that consumer-grade
|
|
96
|
+
// gyro drift can't accumulate visibly before correction. Skipped
|
|
97
|
+
// entirely while interference is active so a magnet doesn't pull
|
|
98
|
+
// the fusion off-truth — gyro carries heading until the field clears.
|
|
99
|
+
private const val MAG_CORRECTION_ALPHA = 0.02
|
|
100
|
+
|
|
101
|
+
// Hard-iron bias jump threshold (µT, per axis). The OS's bias
|
|
102
|
+
// estimate shifts when it observes a sustained change in the local
|
|
103
|
+
// field — placing or removing a magnet on top of the device does
|
|
104
|
+
// exactly this. A jump > 1 µT on any axis means the OS just
|
|
105
|
+
// decided the field environment shifted, which we treat as a soft
|
|
106
|
+
// interference event even when field magnitude stays within the
|
|
107
|
+
// Earth band (e.g. another phone placed on top — moderate
|
|
108
|
+
// perturbation, magnitude check misses it). Only meaningful when
|
|
109
|
+
// we're subscribed to TYPE_MAGNETIC_FIELD_UNCALIBRATED, which
|
|
110
|
+
// reports the bias estimate alongside raw values.
|
|
111
|
+
private const val BIAS_JUMP_UT = 1.0f
|
|
112
|
+
// Grace window after the most recent bias jump. While within this
|
|
113
|
+
// window we treat the mag stack as unsettled (ongoing interference)
|
|
114
|
+
// — gyro alone carries heading until the OS settles its estimate
|
|
115
|
+
// and we trust mag again. 1.5 s comfortably covers OEM bias-update
|
|
116
|
+
// cadence without leaving a long stale interference flag.
|
|
117
|
+
private const val BIAS_JUMP_GRACE_NS = 1_500_000_000L
|
|
118
|
+
|
|
119
|
+
// Tightened interference-band tolerance applied around the
|
|
120
|
+
// user-provided location's expected field strength, when one has
|
|
121
|
+
// been supplied via setLocation(). Earth's field varies from
|
|
122
|
+
// ~25 µT at the equator to ~65 µT near the poles, so a generic
|
|
123
|
+
// 20–70 band is too loose at one extreme and too tight at the
|
|
124
|
+
// other. ±15 µT centered on `expectedFieldUt` catches weak
|
|
125
|
+
// interference while still tolerating sensor noise + altitude
|
|
126
|
+
// variation.
|
|
127
|
+
private const val LOCATION_FIELD_TOLERANCE_UT = 15.0
|
|
59
128
|
}
|
|
60
129
|
|
|
61
130
|
@Volatile private var filterDeg: Double = 1.0
|
|
62
131
|
@Volatile private var lastEmittedHeading: Double = Double.NaN
|
|
63
132
|
@Volatile private var lastAccuracyDeg: Double = -1.0
|
|
133
|
+
@Volatile private var lastFieldUt: Double = -1.0
|
|
64
134
|
@Volatile private var lastSample: CompassSample? = null
|
|
65
135
|
@Volatile private var lastQuality: AccuracyQuality? = null
|
|
66
136
|
@Volatile private var declinationDeg: Double = 0.0
|
|
@@ -68,44 +138,127 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
68
138
|
@Volatile private var started: Boolean = false
|
|
69
139
|
@Volatile private var isSubscribed: Boolean = false
|
|
70
140
|
@Volatile private var lastEventNs: Long = 0L
|
|
141
|
+
// Separate timestamp for game-RV so the watchdog can detect
|
|
142
|
+
// gyro-only freezes (heading silently stops tracking rotation
|
|
143
|
+
// during interference if game-RV freezes alone — the accel+mag
|
|
144
|
+
// pair keeps `lastEventNs` fresh and the watchdog wouldn't fire).
|
|
145
|
+
@Volatile private var lastGameRvEventNs: Long = 0L
|
|
71
146
|
@Volatile private var lastInterference: Boolean? = null
|
|
72
147
|
@Volatile private var currentActivityRef: WeakReference<Activity>? = null
|
|
148
|
+
@Volatile private var smoothedSin: Double = Double.NaN
|
|
149
|
+
@Volatile private var smoothedCos: Double = Double.NaN
|
|
150
|
+
@Volatile private var smoothingAlpha: Double = DEFAULT_SMOOTHING_ALPHA
|
|
151
|
+
// Last raw quality from the OS, before the interference downgrade is
|
|
152
|
+
// applied. Used to re-derive `lastQuality` when interference toggles.
|
|
153
|
+
@Volatile private var lastRawQuality: AccuracyQuality? = null
|
|
154
|
+
// Snapshot of `lastRawQuality` at the moment interference *started*.
|
|
155
|
+
// The OS magnetometer accuracy bucket can stay at LOW/UNRELIABLE for
|
|
156
|
+
// many seconds after a magnet event (it only refreshes when the user
|
|
157
|
+
// moves the phone enough to recalibrate), but the underlying
|
|
158
|
+
// calibration didn't actually change — the field is back in the
|
|
159
|
+
// Earth band, so the heading is trustworthy. Restoring this snapshot
|
|
160
|
+
// when interference clears lets the calibration banner auto-dismiss
|
|
161
|
+
// without forcing the user to perform a motion or tap Refresh.
|
|
162
|
+
@Volatile private var preInterferenceRawQuality: AccuracyQuality? = null
|
|
73
163
|
|
|
74
|
-
|
|
75
|
-
|
|
164
|
+
// Latest *low-pass-filtered* accelerometer + magnetometer vectors.
|
|
165
|
+
// Both are needed for every heading computation; we hold a smoothed
|
|
166
|
+
// version of each (input LP via INPUT_FILTER_ALPHA) and recompute
|
|
167
|
+
// heading on every magnetic event. Filtering at the input — before
|
|
168
|
+
// getRotationMatrix runs — kills jitter much more effectively than
|
|
169
|
+
// filtering the output heading, because the rotation-matrix math
|
|
170
|
+
// amplifies small input noise non-linearly.
|
|
171
|
+
private val latestAccel = FloatArray(3)
|
|
172
|
+
private val latestMag = FloatArray(3)
|
|
173
|
+
@Volatile private var hasAccel = false
|
|
174
|
+
@Volatile private var hasMag = false
|
|
175
|
+
|
|
176
|
+
// Expected magnetic field magnitude at the user's location (µT),
|
|
177
|
+
// computed from `GeomagneticField` (Android's bundled WMM2025) when
|
|
178
|
+
// setLocation() is called. `-1.0` means no location has been
|
|
179
|
+
// supplied — fall back to the generic 20–70 µT band for interference
|
|
180
|
+
// detection.
|
|
181
|
+
@Volatile private var expectedFieldUt: Double = -1.0
|
|
182
|
+
|
|
183
|
+
// Hard-iron bias estimate from TYPE_MAGNETIC_FIELD_UNCALIBRATED.
|
|
184
|
+
// Holds the *previous* event's bias so we can spot jumps; bias jumps
|
|
185
|
+
// happen when the OS revises its hard-iron estimate in response to
|
|
186
|
+
// a changed field environment (e.g. magnet on/off). When we're on
|
|
187
|
+
// the calibrated-mag fallback (no uncalibrated sensor), `hasBias`
|
|
188
|
+
// stays false and these are unused.
|
|
189
|
+
private val lastBias = FloatArray(3)
|
|
190
|
+
@Volatile private var hasBias = false
|
|
191
|
+
@Volatile private var lastBiasJumpNs = 0L
|
|
192
|
+
@Volatile private var usingUncalibratedMag = false
|
|
193
|
+
|
|
194
|
+
private val rotationMatrix = FloatArray(9)
|
|
195
|
+
private val inclinationMatrix = FloatArray(9)
|
|
196
|
+
private val remappedMatrix = FloatArray(9)
|
|
76
197
|
private val orientation = FloatArray(3)
|
|
77
198
|
|
|
199
|
+
// Game-rotation-vector–derived gyro-corrected yaw. Game-RV is
|
|
200
|
+
// gyro+accel only (no mag), so it's *not* poisoned by magnetic
|
|
201
|
+
// events the way TYPE_ROTATION_VECTOR is. We use it as an incremental
|
|
202
|
+
// yaw rate (Δyaw between events) and integrate that into
|
|
203
|
+
// `fusedYawDeg`; magnetometer samples then pull `fusedYawDeg` toward
|
|
204
|
+
// absolute mag-derived yaw via a small complementary blend
|
|
205
|
+
// (MAG_CORRECTION_ALPHA). Net effect: heading tracks fast turns
|
|
206
|
+
// smoothly via gyro, doesn't drift over time thanks to mag, and
|
|
207
|
+
// sails through transient magnet events instead of freezing at
|
|
208
|
+
// last-good. Old/cheap devices may lack a gyro entirely (no game-RV
|
|
209
|
+
// sensor) — we silently fall back to pure mag+accel and behavior is
|
|
210
|
+
// identical to the prior implementation.
|
|
211
|
+
@Volatile private var fusedYawDeg: Double = Double.NaN
|
|
212
|
+
@Volatile private var lastGameRvYawDeg: Double = Double.NaN
|
|
213
|
+
@Volatile private var lastGameRvTimeNs: Long = 0L
|
|
214
|
+
@Volatile private var lastYawRateDegPerS: Double = 0.0
|
|
215
|
+
@Volatile private var hasGameRv: Boolean = false
|
|
216
|
+
// True when interference just cleared and the next mag-derived yaw
|
|
217
|
+
// should snap `fusedYawDeg` directly instead of slowly correcting via
|
|
218
|
+
// MAG_CORRECTION_ALPHA. Without this snap, heading would lag for
|
|
219
|
+
// ~1 s while the complementary filter pulled gyro-integrated truth
|
|
220
|
+
// toward post-magnet truth.
|
|
221
|
+
@Volatile private var seedFusedYaw: Boolean = false
|
|
222
|
+
private val gameRvRotationMatrix = FloatArray(9)
|
|
223
|
+
private val gameRvRemappedMatrix = FloatArray(9)
|
|
224
|
+
private val gameRvOrientation = FloatArray(3)
|
|
225
|
+
|
|
78
226
|
private val epoch = AtomicInteger(0)
|
|
79
227
|
private val activityCounter = AtomicInteger(0)
|
|
80
228
|
private var sensorThread: HandlerThread? = null
|
|
81
229
|
private var sensorHandler: Handler? = null
|
|
82
|
-
private var activeSensor: Sensor? = null
|
|
83
230
|
private var activeListener: SensorEventListener? = null
|
|
84
231
|
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
232
|
+
// Callback fields are written from the JS thread (start, setOnX) and
|
|
233
|
+
// read from the sensor HandlerThread on every event. @Volatile gives
|
|
234
|
+
// them a happens-before guarantee so a late-registered listener
|
|
235
|
+
// becomes visible to the sensor thread on the next event.
|
|
236
|
+
@Volatile private var onHeading: ((CompassSample) -> Unit)? = null
|
|
237
|
+
@Volatile private var calibrationCb: ((AccuracyQuality) -> Unit)? = null
|
|
238
|
+
@Volatile private var interferenceCb: ((Boolean) -> Unit)? = null
|
|
88
239
|
|
|
89
240
|
private val watchdogHandler = Handler(Looper.getMainLooper())
|
|
90
241
|
private val watchdogRunnable = object : Runnable {
|
|
91
242
|
override fun run() {
|
|
92
243
|
// When the app is backgrounded, the OS legitimately suspends or
|
|
93
244
|
// throttles non-wake-up sensors (Doze on API 23+, background limits
|
|
94
|
-
// on API 26+). Re-registering
|
|
95
|
-
//
|
|
96
|
-
// and re-arm; we'll resume normal watchdog behaviour on foreground.
|
|
245
|
+
// on API 26+). Re-registering won't change that — it just burns
|
|
246
|
+
// power flapping every 1.5s. Skip the staleness check and re-arm.
|
|
97
247
|
val backgrounded = activityCounter.get() == 0
|
|
98
|
-
val last = lastEventNs
|
|
99
248
|
val now = SystemClock.elapsedRealtimeNanos()
|
|
100
|
-
|
|
249
|
+
val accelMagStale = lastEventNs > 0L && now - lastEventNs > STALE_THRESHOLD_NS
|
|
250
|
+
// Only watchdog game-RV if we actually have it — older devices
|
|
251
|
+
// without a gyro never produce game-RV events, and we shouldn't
|
|
252
|
+
// re-subscribe everything just because game-RV is silent there.
|
|
253
|
+
val gameRvStale = hasGameRv && lastGameRvEventNs > 0L &&
|
|
254
|
+
now - lastGameRvEventNs > STALE_THRESHOLD_NS
|
|
255
|
+
if (!backgrounded && (accelMagStale || gameRvStale)) {
|
|
101
256
|
synchronized(this@HybridNitroCompass) {
|
|
102
257
|
if (started && isSubscribed) {
|
|
103
258
|
unsubscribeLocked()
|
|
104
259
|
subscribeLocked()
|
|
105
|
-
// subscribeLocked() re-arms the watchdog itself, so don't
|
|
106
|
-
// double-post below. Reset the timestamp to give the fresh
|
|
107
|
-
// subscription a full window before being judged stale.
|
|
108
260
|
lastEventNs = SystemClock.elapsedRealtimeNanos()
|
|
261
|
+
lastGameRvEventNs = 0L
|
|
109
262
|
}
|
|
110
263
|
}
|
|
111
264
|
return
|
|
@@ -124,12 +277,17 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
124
277
|
synchronized(this) {
|
|
125
278
|
stopLocked()
|
|
126
279
|
started = true
|
|
127
|
-
|
|
280
|
+
// NaN/-Inf would silently freeze heading delivery (`delta < NaN`
|
|
281
|
+
// is always false, suppressing every sample). Coerce to a sane
|
|
282
|
+
// default — same defensive policy as setFilter().
|
|
283
|
+
filterDeg = if (filterDegrees.isFinite()) filterDegrees.coerceAtLeast(0.0) else 0.0
|
|
128
284
|
this.onHeading = onHeading
|
|
129
285
|
lastEmittedHeading = Double.NaN
|
|
130
286
|
lastAccuracyDeg = -1.0
|
|
287
|
+
lastFieldUt = -1.0
|
|
131
288
|
lastSample = null
|
|
132
289
|
lastQuality = null
|
|
290
|
+
lastRawQuality = null
|
|
133
291
|
|
|
134
292
|
registerLifecycleCallbacks()
|
|
135
293
|
subscribeLocked()
|
|
@@ -145,34 +303,87 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
145
303
|
override fun hasCompass(): Boolean {
|
|
146
304
|
val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
147
305
|
?: return false
|
|
148
|
-
|
|
149
|
-
|
|
306
|
+
if (sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) == null) return false
|
|
307
|
+
return sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED) != null ||
|
|
308
|
+
sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) != null
|
|
150
309
|
}
|
|
151
310
|
|
|
152
311
|
override fun isStarted(): Boolean = started
|
|
153
312
|
|
|
154
313
|
override fun setFilter(degrees: Double) {
|
|
314
|
+
// Reject NaN/-Inf: these compare unordered against any value, so
|
|
315
|
+
// the deadband check (`delta < filterDeg`) would silently suppress
|
|
316
|
+
// every sample if filterDeg became NaN.
|
|
317
|
+
if (!degrees.isFinite()) return
|
|
155
318
|
filterDeg = degrees.coerceAtLeast(0.0)
|
|
156
319
|
}
|
|
157
320
|
|
|
321
|
+
override fun setSmoothing(alpha: Double) {
|
|
322
|
+
// Lower bound is 0.01 (not 0): at α=0 the EMA never updates, so
|
|
323
|
+
// `smoothedSin/Cos` would freeze at the first sample and the
|
|
324
|
+
// surfaced heading would never move — a footgun if a caller
|
|
325
|
+
// computes a small alpha and accidentally rounds to zero.
|
|
326
|
+
if (!alpha.isFinite()) return
|
|
327
|
+
smoothingAlpha = alpha.coerceIn(0.01, 1.0)
|
|
328
|
+
}
|
|
329
|
+
|
|
158
330
|
override fun getDiagnostics(): SensorDiagnostics? {
|
|
159
331
|
val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
160
332
|
?: return null
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
333
|
+
if (sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) == null) return null
|
|
334
|
+
val hasMagSensor = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED) != null ||
|
|
335
|
+
sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) != null
|
|
336
|
+
if (!hasMagSensor) return null
|
|
337
|
+
return SensorDiagnostics(SensorKind.MAGNETOMETER)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
override fun getDebugInfo(): DebugInfo {
|
|
341
|
+
val msSinceJump = if (lastBiasJumpNs > 0L) {
|
|
342
|
+
(SystemClock.elapsedRealtimeNanos() - lastBiasJumpNs) / 1_000_000.0
|
|
343
|
+
} else {
|
|
344
|
+
-1.0
|
|
167
345
|
}
|
|
346
|
+
return DebugInfo(
|
|
347
|
+
interferenceActive = lastInterference == true,
|
|
348
|
+
msSinceLastBiasJump = msSinceJump,
|
|
349
|
+
expectedFieldMicroTesla = expectedFieldUt,
|
|
350
|
+
lastFieldMicroTesla = lastFieldUt,
|
|
351
|
+
fusedYawDeg = fusedYawDeg,
|
|
352
|
+
lastYawRateDegPerS = lastYawRateDegPerS,
|
|
353
|
+
hasGameRotationVector = hasGameRv,
|
|
354
|
+
usingUncalibratedMag = usingUncalibratedMag
|
|
355
|
+
)
|
|
168
356
|
}
|
|
169
357
|
|
|
170
358
|
override fun getCurrentHeading(): CompassSample? = lastSample
|
|
171
359
|
|
|
172
360
|
override fun setDeclination(degrees: Double) {
|
|
361
|
+
// NaN propagates through the emit math (heading + declinationDeg)
|
|
362
|
+
// and would poison every emission until reset.
|
|
363
|
+
if (!degrees.isFinite()) return
|
|
173
364
|
declinationDeg = degrees
|
|
174
365
|
}
|
|
175
366
|
|
|
367
|
+
override fun setLocation(latitude: Double, longitude: Double) {
|
|
368
|
+
val invalid = latitude.isNaN() || longitude.isNaN() ||
|
|
369
|
+
abs(latitude) > 90.0 || abs(longitude) > 180.0
|
|
370
|
+
expectedFieldUt = if (invalid) {
|
|
371
|
+
// Caller signaled "clear" — revert to the generic 20–70 µT band.
|
|
372
|
+
-1.0
|
|
373
|
+
} else {
|
|
374
|
+
// GeomagneticField returns nT; we work in µT throughout the rest
|
|
375
|
+
// of the file, so divide. Altitude defaults to 0 m — its effect
|
|
376
|
+
// is < 0.03 % per km, well inside the ±15 µT tolerance.
|
|
377
|
+
val gf = GeomagneticField(
|
|
378
|
+
latitude.toFloat(),
|
|
379
|
+
longitude.toFloat(),
|
|
380
|
+
0f,
|
|
381
|
+
System.currentTimeMillis()
|
|
382
|
+
)
|
|
383
|
+
gf.fieldStrength.toDouble() / 1000.0
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
176
387
|
override fun setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) -> Unit) {
|
|
177
388
|
calibrationCb = onChange
|
|
178
389
|
}
|
|
@@ -196,6 +407,46 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
196
407
|
}
|
|
197
408
|
}
|
|
198
409
|
|
|
410
|
+
override fun recalibrate() {
|
|
411
|
+
synchronized(this) {
|
|
412
|
+
if (!started) return
|
|
413
|
+
// Re-register the sensor listeners. On many Android OEMs the
|
|
414
|
+
// unregister/register cycle nudges the magnetometer driver into
|
|
415
|
+
// re-evaluating soft/hard-iron calibration, which can unstick an
|
|
416
|
+
// UNRELIABLE bucket that's lingering after a strong field
|
|
417
|
+
// excursion. At minimum it gives the consumer a deterministic
|
|
418
|
+
// "try again" button.
|
|
419
|
+
val wasSubscribed = isSubscribed
|
|
420
|
+
if (wasSubscribed) unsubscribeLocked()
|
|
421
|
+
|
|
422
|
+
// Wipe everything that could carry stale state into the fresh
|
|
423
|
+
// subscription. The next sensor events will reseed the input LP,
|
|
424
|
+
// the output EMA, and the gyro fusion from current truth.
|
|
425
|
+
lastEmittedHeading = Double.NaN
|
|
426
|
+
lastAccuracyDeg = -1.0
|
|
427
|
+
lastFieldUt = -1.0
|
|
428
|
+
lastSample = null
|
|
429
|
+
lastQuality = null
|
|
430
|
+
lastRawQuality = null
|
|
431
|
+
preInterferenceRawQuality = null
|
|
432
|
+
lastInterference = null
|
|
433
|
+
smoothedSin = Double.NaN
|
|
434
|
+
smoothedCos = Double.NaN
|
|
435
|
+
hasAccel = false
|
|
436
|
+
hasMag = false
|
|
437
|
+
hasBias = false
|
|
438
|
+
lastBiasJumpNs = 0L
|
|
439
|
+
fusedYawDeg = Double.NaN
|
|
440
|
+
lastGameRvYawDeg = Double.NaN
|
|
441
|
+
lastGameRvTimeNs = 0L
|
|
442
|
+
lastYawRateDegPerS = 0.0
|
|
443
|
+
hasGameRv = false
|
|
444
|
+
seedFusedYaw = false
|
|
445
|
+
|
|
446
|
+
if (wasSubscribed) subscribeLocked()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
199
450
|
// Sensors don't require a runtime permission on Android, so both
|
|
200
451
|
// permission methods are unconditionally granted.
|
|
201
452
|
override fun getPermissionStatus(): PermissionStatus = PermissionStatus.GRANTED
|
|
@@ -214,15 +465,27 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
214
465
|
lastSample = null
|
|
215
466
|
lastQuality = null
|
|
216
467
|
lastInterference = null
|
|
468
|
+
preInterferenceRawQuality = null
|
|
217
469
|
}
|
|
218
470
|
|
|
219
471
|
private fun subscribeLocked() {
|
|
220
472
|
if (isSubscribed) return
|
|
221
473
|
val sm = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
222
474
|
?: throw IllegalStateException("SensorManager unavailable")
|
|
223
|
-
val
|
|
224
|
-
?:
|
|
225
|
-
|
|
475
|
+
val accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
|
476
|
+
?: throw IllegalStateException("No accelerometer on this device")
|
|
477
|
+
// Prefer TYPE_MAGNETIC_FIELD_UNCALIBRATED — it reports the OS's
|
|
478
|
+
// hard-iron bias estimate alongside raw values, so we can detect
|
|
479
|
+
// bias jumps (a much more reliable interference signal than field
|
|
480
|
+
// magnitude alone — catches "weak" magnet events where the
|
|
481
|
+
// magnitude stays in the Earth band but the OS still revises its
|
|
482
|
+
// bias). We apply the bias correction ourselves, so the heading
|
|
483
|
+
// computation is identical to the calibrated path.
|
|
484
|
+
val magUncal = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED)
|
|
485
|
+
val magCal = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
|
486
|
+
val mag = magUncal ?: magCal
|
|
487
|
+
?: throw IllegalStateException("No magnetometer on this device")
|
|
488
|
+
usingUncalibratedMag = magUncal != null
|
|
226
489
|
|
|
227
490
|
val myEpoch = epoch.incrementAndGet()
|
|
228
491
|
val thread = HandlerThread("NitroCompass-Sensor").also { it.start() }
|
|
@@ -235,26 +498,50 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
235
498
|
|
|
236
499
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
|
237
500
|
if (myEpoch != epoch.get()) return
|
|
238
|
-
|
|
501
|
+
// The magnetometer's accuracy bucket is the figure-8 calibration
|
|
502
|
+
// signal we want to surface. Accelerometer accuracy is rarely
|
|
503
|
+
// meaningful for compass UX — ignore it. Both calibrated and
|
|
504
|
+
// uncalibrated mag report the same bucket semantics, so accept
|
|
505
|
+
// either.
|
|
506
|
+
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD ||
|
|
507
|
+
sensor.type == Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED
|
|
508
|
+
) {
|
|
509
|
+
handleMagAccuracyChanged(accuracy)
|
|
510
|
+
}
|
|
239
511
|
}
|
|
240
512
|
}
|
|
241
|
-
sm.registerListener(listener,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
513
|
+
sm.registerListener(listener, accel, SensorManager.SENSOR_DELAY_GAME, handler)
|
|
514
|
+
sm.registerListener(listener, mag, SensorManager.SENSOR_DELAY_GAME, handler)
|
|
515
|
+
|
|
516
|
+
// Game-RV is *optional*. It's gyro+accel only (no mag), so it's
|
|
517
|
+
// immune to magnetic interference and never bias-poisoned the way
|
|
518
|
+
// TYPE_ROTATION_VECTOR is. Old/cheap devices may lack a gyro
|
|
519
|
+
// entirely — we silently fall back to pure mag+accel and behavior
|
|
520
|
+
// stays identical to the prior implementation.
|
|
521
|
+
val gameRv: Sensor? = sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
|
|
522
|
+
gameRv?.let {
|
|
523
|
+
sm.registerListener(listener, it, SensorManager.SENSOR_DELAY_GAME, handler)
|
|
249
524
|
}
|
|
250
525
|
|
|
251
526
|
sensorThread = thread
|
|
252
527
|
sensorHandler = handler
|
|
253
|
-
activeSensor = sensor
|
|
254
528
|
activeListener = listener
|
|
255
529
|
isSubscribed = true
|
|
256
530
|
|
|
531
|
+
hasAccel = false
|
|
532
|
+
hasMag = false
|
|
533
|
+
hasBias = false
|
|
534
|
+
lastBiasJumpNs = 0L
|
|
535
|
+
smoothedSin = Double.NaN
|
|
536
|
+
smoothedCos = Double.NaN
|
|
537
|
+
fusedYawDeg = Double.NaN
|
|
538
|
+
lastGameRvYawDeg = Double.NaN
|
|
539
|
+
lastGameRvTimeNs = 0L
|
|
540
|
+
lastYawRateDegPerS = 0.0
|
|
541
|
+
hasGameRv = false
|
|
542
|
+
seedFusedYaw = false
|
|
257
543
|
lastEventNs = 0L
|
|
544
|
+
lastGameRvEventNs = 0L
|
|
258
545
|
watchdogHandler.removeCallbacks(watchdogRunnable)
|
|
259
546
|
watchdogHandler.postDelayed(watchdogRunnable, WATCHDOG_PERIOD_MS)
|
|
260
547
|
}
|
|
@@ -265,7 +552,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
265
552
|
sensorThread?.quitSafely()
|
|
266
553
|
sensorThread = null
|
|
267
554
|
sensorHandler = null
|
|
268
|
-
activeSensor = null
|
|
269
555
|
activeListener = null
|
|
270
556
|
return
|
|
271
557
|
}
|
|
@@ -273,7 +559,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
273
559
|
val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
274
560
|
activeListener?.let { sm?.unregisterListener(it) }
|
|
275
561
|
activeListener = null
|
|
276
|
-
activeSensor = null
|
|
277
562
|
sensorHandler = null
|
|
278
563
|
sensorThread?.quitSafely()
|
|
279
564
|
sensorThread = null
|
|
@@ -283,10 +568,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
283
568
|
private fun registerLifecycleCallbacks() {
|
|
284
569
|
if (lifecycleCallbacks != null) return
|
|
285
570
|
val app = NitroModules.applicationContext?.applicationContext as? Application ?: return
|
|
286
|
-
// start() can be called from a headless / background context (e.g. a
|
|
287
|
-
// headless JS task, or before any Activity has come up). Don't assume
|
|
288
|
-
// foreground — query the OS so pauseOnBackground=true actually keeps
|
|
289
|
-
// the sensor unsubscribed when the app isn't user-visible.
|
|
290
571
|
activityCounter.set(if (isAppInForeground(app)) 1 else 0)
|
|
291
572
|
val cb = object : Application.ActivityLifecycleCallbacks {
|
|
292
573
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
|
@@ -356,18 +637,181 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
356
637
|
}
|
|
357
638
|
|
|
358
639
|
private fun handleSensorEvent(event: SensorEvent) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
640
|
+
when (event.sensor.type) {
|
|
641
|
+
Sensor.TYPE_ACCELEROMETER -> {
|
|
642
|
+
if (!event.values[0].isFinite() || !event.values[1].isFinite() ||
|
|
643
|
+
!event.values[2].isFinite()
|
|
644
|
+
) return
|
|
645
|
+
if (!hasAccel) {
|
|
646
|
+
// Seed with the first sample to avoid an artificial ramp-up
|
|
647
|
+
// from zero, which would skew the orientation calc for
|
|
648
|
+
// ~1 second after subscription.
|
|
649
|
+
latestAccel[0] = event.values[0]
|
|
650
|
+
latestAccel[1] = event.values[1]
|
|
651
|
+
latestAccel[2] = event.values[2]
|
|
652
|
+
} else {
|
|
653
|
+
val a = adaptiveInputAlpha(INPUT_FILTER_ALPHA_ACCEL_STILL, INPUT_FILTER_ALPHA_ACCEL_FAST)
|
|
654
|
+
latestAccel[0] = a * event.values[0] + (1f - a) * latestAccel[0]
|
|
655
|
+
latestAccel[1] = a * event.values[1] + (1f - a) * latestAccel[1]
|
|
656
|
+
latestAccel[2] = a * event.values[2] + (1f - a) * latestAccel[2]
|
|
657
|
+
}
|
|
658
|
+
hasAccel = true
|
|
659
|
+
lastEventNs = SystemClock.elapsedRealtimeNanos()
|
|
660
|
+
}
|
|
661
|
+
Sensor.TYPE_MAGNETIC_FIELD, Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED -> {
|
|
662
|
+
// Uncalibrated mag delivers 6 floats: raw[0..2] and the OS's
|
|
663
|
+
// hard-iron bias estimate[3..5]. We apply the bias correction
|
|
664
|
+
// ourselves (mathematically equivalent to the calibrated path)
|
|
665
|
+
// and additionally use bias *jumps* as a separate interference
|
|
666
|
+
// signal — when the OS revises its bias, the field environment
|
|
667
|
+
// just changed (likely a magnet on/off), even if the corrected
|
|
668
|
+
// magnitude stays in the Earth band.
|
|
669
|
+
if (!event.values[0].isFinite() || !event.values[1].isFinite() ||
|
|
670
|
+
!event.values[2].isFinite()
|
|
671
|
+
) return
|
|
672
|
+
val correctedX: Float
|
|
673
|
+
val correctedY: Float
|
|
674
|
+
val correctedZ: Float
|
|
675
|
+
if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED) {
|
|
676
|
+
val biasX = event.values[3]
|
|
677
|
+
val biasY = event.values[4]
|
|
678
|
+
val biasZ = event.values[5]
|
|
679
|
+
if (hasBias) {
|
|
680
|
+
if (abs(biasX - lastBias[0]) > BIAS_JUMP_UT ||
|
|
681
|
+
abs(biasY - lastBias[1]) > BIAS_JUMP_UT ||
|
|
682
|
+
abs(biasZ - lastBias[2]) > BIAS_JUMP_UT
|
|
683
|
+
) {
|
|
684
|
+
lastBiasJumpNs = SystemClock.elapsedRealtimeNanos()
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
lastBias[0] = biasX
|
|
688
|
+
lastBias[1] = biasY
|
|
689
|
+
lastBias[2] = biasZ
|
|
690
|
+
hasBias = true
|
|
691
|
+
correctedX = event.values[0] - biasX
|
|
692
|
+
correctedY = event.values[1] - biasY
|
|
693
|
+
correctedZ = event.values[2] - biasZ
|
|
694
|
+
} else {
|
|
695
|
+
// Calibrated mag — the OS already subtracted its bias estimate.
|
|
696
|
+
correctedX = event.values[0]
|
|
697
|
+
correctedY = event.values[1]
|
|
698
|
+
correctedZ = event.values[2]
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!hasMag) {
|
|
702
|
+
latestMag[0] = correctedX
|
|
703
|
+
latestMag[1] = correctedY
|
|
704
|
+
latestMag[2] = correctedZ
|
|
705
|
+
} else {
|
|
706
|
+
val a = adaptiveInputAlpha(INPUT_FILTER_ALPHA_MAG_STILL, INPUT_FILTER_ALPHA_MAG_FAST)
|
|
707
|
+
latestMag[0] = a * correctedX + (1f - a) * latestMag[0]
|
|
708
|
+
latestMag[1] = a * correctedY + (1f - a) * latestMag[1]
|
|
709
|
+
latestMag[2] = a * correctedZ + (1f - a) * latestMag[2]
|
|
710
|
+
}
|
|
711
|
+
hasMag = true
|
|
712
|
+
lastEventNs = SystemClock.elapsedRealtimeNanos()
|
|
367
713
|
|
|
368
|
-
|
|
714
|
+
// Use *raw* (unfiltered) corrected magnitude for the
|
|
715
|
+
// interference signal so it responds instantly to spikes. The
|
|
716
|
+
// LP-filtered vector is only used downstream for the heading
|
|
717
|
+
// calc. Interference is OR-combined with the bias-jump signal
|
|
718
|
+
// inside evaluateInterference.
|
|
719
|
+
val rx = correctedX.toDouble()
|
|
720
|
+
val ry = correctedY.toDouble()
|
|
721
|
+
val rz = correctedZ.toDouble()
|
|
722
|
+
val magnitude = sqrt(rx * rx + ry * ry + rz * rz)
|
|
723
|
+
lastFieldUt = magnitude
|
|
724
|
+
evaluateInterference(magnitude)
|
|
369
725
|
|
|
370
|
-
|
|
726
|
+
// Recompute heading on every mag event (the limiting-rate
|
|
727
|
+
// sensor). If we don't have an accelerometer reading yet, hold
|
|
728
|
+
// off — we need both for getRotationMatrix.
|
|
729
|
+
if (hasAccel) computeAndDeliverHeading()
|
|
730
|
+
}
|
|
731
|
+
Sensor.TYPE_GAME_ROTATION_VECTOR -> {
|
|
732
|
+
// Game-RV is gyro+accel only. We extract a *yaw delta* between
|
|
733
|
+
// events, integrate it into `fusedYawDeg`, and let mag samples
|
|
734
|
+
// pull `fusedYawDeg` toward absolute truth via the small blend
|
|
735
|
+
// in computeAndDeliverHeading. We track its freshness in a
|
|
736
|
+
// separate `lastGameRvEventNs` (not the shared `lastEventNs`)
|
|
737
|
+
// so the watchdog can detect a gyro-only freeze without being
|
|
738
|
+
// suppressed by accel/mag continuing to fire.
|
|
739
|
+
// Reject non-finite sensor values up-front. Some Samsung /
|
|
740
|
+
// MediaTek HALs occasionally emit a single NaN sample; without
|
|
741
|
+
// this guard, NaN would propagate through getRotationMatrixFromVector
|
|
742
|
+
// and permanently poison `fusedYawDeg` until recalibrate().
|
|
743
|
+
if (!event.values[0].isFinite() || !event.values[1].isFinite() ||
|
|
744
|
+
!event.values[2].isFinite() || !event.values[3].isFinite()
|
|
745
|
+
) return
|
|
746
|
+
lastGameRvEventNs = SystemClock.elapsedRealtimeNanos()
|
|
747
|
+
SensorManager.getRotationMatrixFromVector(gameRvRotationMatrix, event.values)
|
|
748
|
+
val (axisX, axisY) = when (currentSurfaceRotation()) {
|
|
749
|
+
Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X
|
|
750
|
+
Surface.ROTATION_180 -> SensorManager.AXIS_MINUS_X to SensorManager.AXIS_MINUS_Y
|
|
751
|
+
Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X
|
|
752
|
+
else -> SensorManager.AXIS_X to SensorManager.AXIS_Y
|
|
753
|
+
}
|
|
754
|
+
// remapCoordinateSystem returns false for invalid axis pairs
|
|
755
|
+
// (the input matrix is left as zero-init garbage). Skip the
|
|
756
|
+
// sample on failure so getOrientation() doesn't read from a
|
|
757
|
+
// stale/zero matrix.
|
|
758
|
+
if (!SensorManager.remapCoordinateSystem(
|
|
759
|
+
gameRvRotationMatrix, axisX, axisY, gameRvRemappedMatrix
|
|
760
|
+
)
|
|
761
|
+
) return
|
|
762
|
+
SensorManager.getOrientation(gameRvRemappedMatrix, gameRvOrientation)
|
|
763
|
+
var yawDeg = Math.toDegrees(gameRvOrientation[0].toDouble())
|
|
764
|
+
if (yawDeg.isNaN()) return
|
|
765
|
+
if (yawDeg < 0.0) yawDeg += 360.0
|
|
766
|
+
|
|
767
|
+
val nowNs = SystemClock.elapsedRealtimeNanos()
|
|
768
|
+
if (lastGameRvTimeNs > 0L && !lastGameRvYawDeg.isNaN()) {
|
|
769
|
+
// Wrap Δyaw to (-180, 180] so a yaw transition like
|
|
770
|
+
// 350°→10° produces +20° rather than -340°.
|
|
771
|
+
var dYaw = yawDeg - lastGameRvYawDeg
|
|
772
|
+
while (dYaw > 180.0) dYaw -= 360.0
|
|
773
|
+
while (dYaw < -180.0) dYaw += 360.0
|
|
774
|
+
val dtSec = (nowNs - lastGameRvTimeNs) / 1e9
|
|
775
|
+
if (dtSec > 0.001) {
|
|
776
|
+
// EMA the yaw rate so a single 100°/s spike on a still
|
|
777
|
+
// device can't transiently weaken the input low-pass and
|
|
778
|
+
// amplify steady-state jitter. α=0.3 → ~3-sample window.
|
|
779
|
+
val instantaneous = dYaw / dtSec
|
|
780
|
+
lastYawRateDegPerS = if (lastYawRateDegPerS == 0.0) {
|
|
781
|
+
instantaneous
|
|
782
|
+
} else {
|
|
783
|
+
0.3 * instantaneous + 0.7 * lastYawRateDegPerS
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (!fusedYawDeg.isNaN()) {
|
|
787
|
+
// Integrate gyro-derived yaw rate into the fused estimate.
|
|
788
|
+
val next = wrap360(fusedYawDeg + dYaw)
|
|
789
|
+
// Defensive: if any prior arithmetic poisoned fusedYawDeg
|
|
790
|
+
// with NaN (e.g. via a downstream mixYawCircular that read
|
|
791
|
+
// NaN inputs), reseed from raw mag yaw on the next mag
|
|
792
|
+
// sample by leaving fusedYawDeg as NaN here.
|
|
793
|
+
fusedYawDeg = if (next.isFinite()) next else Double.NaN
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
lastGameRvYawDeg = yawDeg
|
|
797
|
+
lastGameRvTimeNs = nowNs
|
|
798
|
+
hasGameRv = true
|
|
799
|
+
}
|
|
800
|
+
else -> Unit
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private fun computeAndDeliverHeading() {
|
|
805
|
+
val ok = SensorManager.getRotationMatrix(
|
|
806
|
+
rotationMatrix,
|
|
807
|
+
inclinationMatrix,
|
|
808
|
+
latestAccel,
|
|
809
|
+
latestMag
|
|
810
|
+
)
|
|
811
|
+
// getRotationMatrix returns false in degenerate cases (e.g. accel
|
|
812
|
+
// vector parallel to mag vector — extremely rare in practice).
|
|
813
|
+
// Skip the sample rather than emit garbage.
|
|
814
|
+
if (!ok) return
|
|
371
815
|
|
|
372
816
|
val (axisX, axisY) = when (currentSurfaceRotation()) {
|
|
373
817
|
Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X
|
|
@@ -375,84 +819,217 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
375
819
|
Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X
|
|
376
820
|
else -> SensorManager.AXIS_X to SensorManager.AXIS_Y
|
|
377
821
|
}
|
|
378
|
-
SensorManager.remapCoordinateSystem(
|
|
822
|
+
if (!SensorManager.remapCoordinateSystem(
|
|
823
|
+
rotationMatrix, axisX, axisY, remappedMatrix
|
|
824
|
+
)
|
|
825
|
+
) return
|
|
379
826
|
SensorManager.getOrientation(remappedMatrix, orientation)
|
|
380
827
|
|
|
381
|
-
var
|
|
382
|
-
if (
|
|
828
|
+
var magYawDeg = Math.toDegrees(orientation[0].toDouble())
|
|
829
|
+
if (magYawDeg.isNaN()) return
|
|
830
|
+
if (magYawDeg < 0.0) magYawDeg += 360.0
|
|
383
831
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
832
|
+
// Drive the output from `fusedYawDeg` whenever we have a working
|
|
833
|
+
// gyro stream. Mag samples *correct* the gyro-integrated estimate
|
|
834
|
+
// toward absolute truth via a small complementary blend; during
|
|
835
|
+
// active interference we skip the correction entirely so a magnet
|
|
836
|
+
// can't pull fusion off-truth, and gyro alone carries heading
|
|
837
|
+
// until the field clears. When interference has *just* cleared,
|
|
838
|
+
// `seedFusedYaw` forces a one-shot snap so the user sees the new
|
|
839
|
+
// truth immediately instead of lagging through the ~1 s mag-blend
|
|
840
|
+
// time constant.
|
|
841
|
+
val headingDeg: Double = if (!hasGameRv) {
|
|
842
|
+
// No gyro available — fall back to the prior pure-mag path.
|
|
843
|
+
// Keep `fusedYawDeg` seeded for the moment game-RV starts firing.
|
|
844
|
+
fusedYawDeg = magYawDeg
|
|
845
|
+
magYawDeg
|
|
846
|
+
} else if (fusedYawDeg.isNaN() || !fusedYawDeg.isFinite() || seedFusedYaw) {
|
|
847
|
+
// Either we've never seeded fusion yet, or a prior arithmetic step
|
|
848
|
+
// poisoned it with NaN/Inf. Either way, snap to the current
|
|
849
|
+
// mag-derived truth so a single bad sample can't permanently
|
|
850
|
+
// freeze heading delivery.
|
|
851
|
+
seedFusedYaw = false
|
|
852
|
+
fusedYawDeg = magYawDeg
|
|
853
|
+
magYawDeg
|
|
854
|
+
} else if (lastInterference == true) {
|
|
855
|
+
// Trust gyro alone during interference — skip the mag mix.
|
|
856
|
+
fusedYawDeg
|
|
857
|
+
} else {
|
|
858
|
+
val mixed = mixYawCircular(fusedYawDeg, magYawDeg, MAG_CORRECTION_ALPHA)
|
|
859
|
+
fusedYawDeg = if (mixed.isFinite()) mixed else magYawDeg
|
|
860
|
+
fusedYawDeg
|
|
388
861
|
}
|
|
389
862
|
|
|
863
|
+
val smoothed = smoothHeading(headingDeg)
|
|
864
|
+
|
|
390
865
|
val prev = lastEmittedHeading
|
|
391
|
-
val delta = if (prev.isNaN()) Double.MAX_VALUE else shortestArc(prev,
|
|
866
|
+
val delta = if (prev.isNaN()) Double.MAX_VALUE else shortestArc(prev, smoothed)
|
|
392
867
|
if (filterDeg > 0.0 && delta < filterDeg) return
|
|
393
|
-
lastEmittedHeading =
|
|
868
|
+
lastEmittedHeading = smoothed
|
|
394
869
|
|
|
395
|
-
var emitted =
|
|
870
|
+
var emitted = smoothed + declinationDeg
|
|
396
871
|
emitted = ((emitted % 360.0) + 360.0) % 360.0
|
|
397
|
-
val sample = CompassSample(emitted, lastAccuracyDeg)
|
|
872
|
+
val sample = CompassSample(emitted, lastAccuracyDeg, lastFieldUt)
|
|
398
873
|
lastSample = sample
|
|
399
874
|
onHeading?.invoke(sample)
|
|
400
875
|
}
|
|
401
876
|
|
|
402
|
-
private fun
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (
|
|
877
|
+
private fun evaluateInterference(magnitude: Double) {
|
|
878
|
+
// When a user location has been provided via setLocation(), the
|
|
879
|
+
// interference band is centered on the WMM-derived expected field
|
|
880
|
+
// strength with a tight ±LOCATION_FIELD_TOLERANCE_UT margin —
|
|
881
|
+
// catches weak interference the generic 20–70 µT band misses.
|
|
882
|
+
// Without a location, we fall back to the generic band that
|
|
883
|
+
// covers Earth's full latitude range.
|
|
884
|
+
val (minUt, maxUt) = if (expectedFieldUt > 0.0) {
|
|
885
|
+
Pair(
|
|
886
|
+
expectedFieldUt - LOCATION_FIELD_TOLERANCE_UT,
|
|
887
|
+
expectedFieldUt + LOCATION_FIELD_TOLERANCE_UT
|
|
888
|
+
)
|
|
889
|
+
} else {
|
|
890
|
+
Pair(EARTH_FIELD_MIN_UT, EARTH_FIELD_MAX_UT)
|
|
891
|
+
}
|
|
892
|
+
val magnitudeOutOfBand = magnitude < minUt || magnitude > maxUt
|
|
893
|
+
// A recent OS bias jump (only available on the uncalibrated mag
|
|
894
|
+
// path) means the field environment shifted enough for the OS to
|
|
895
|
+
// revise its hard-iron estimate. That's a reliable signal of a
|
|
896
|
+
// magnet-on/off event even when the corrected magnitude stays in
|
|
897
|
+
// the Earth band — common when one phone is placed on top of
|
|
898
|
+
// another. We hold this gate true for BIAS_JUMP_GRACE_NS after
|
|
899
|
+
// the most recent jump, then let it fall back to magnitude alone.
|
|
900
|
+
val biasJumpRecent = lastBiasJumpNs > 0L &&
|
|
901
|
+
(SystemClock.elapsedRealtimeNanos() - lastBiasJumpNs) < BIAS_JUMP_GRACE_NS
|
|
902
|
+
val isInterference = magnitudeOutOfBand || biasJumpRecent
|
|
903
|
+
val previous = lastInterference
|
|
904
|
+
if (previous == isInterference) return
|
|
410
905
|
lastInterference = isInterference
|
|
411
906
|
interferenceCb?.invoke(isInterference)
|
|
907
|
+
|
|
908
|
+
if (previous != true && isInterference) {
|
|
909
|
+
// Interference just started — snapshot the raw quality so we
|
|
910
|
+
// can restore it when the field clears. The OS will likely
|
|
911
|
+
// downgrade the bucket while the magnet is present; that
|
|
912
|
+
// downgrade reflects the *symptom*, not a real change in
|
|
913
|
+
// calibration state.
|
|
914
|
+
preInterferenceRawQuality = lastRawQuality
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// When interference clears, the EMA on (sin θ, cos θ) is still
|
|
918
|
+
// weighted with magnet-influenced samples from the last few
|
|
919
|
+
// hundred ms. Resetting it forces the next emission to snap to the
|
|
920
|
+
// current (post-magnet) truth instead of dragging the bad average
|
|
921
|
+
// forward. Same for the input LP — a magnet pulls the magnetometer
|
|
922
|
+
// far enough that the LP needs many samples to forget it.
|
|
923
|
+
//
|
|
924
|
+
// `seedFusedYaw` makes the next mag-derived yaw replace the
|
|
925
|
+
// gyro-integrated `fusedYawDeg` outright, instead of correcting
|
|
926
|
+
// it via the slow ~1 s complementary blend. Without this snap the
|
|
927
|
+
// user sees a visible lag while the blend pulls fusion toward the
|
|
928
|
+
// post-magnet truth.
|
|
929
|
+
//
|
|
930
|
+
// We also restore the pre-interference quality bucket. Without this,
|
|
931
|
+
// the OS's lazy refresh leaves the bucket at UNRELIABLE long after
|
|
932
|
+
// the field is back in the Earth band, so the calibration banner
|
|
933
|
+
// hangs even though the heading is correct. If we have no snapshot
|
|
934
|
+
// (interference fired before the OS ever reported a bucket), we
|
|
935
|
+
// default to MEDIUM — a usable heading that doesn't trigger the
|
|
936
|
+
// calibration banner. The OS will downgrade us via
|
|
937
|
+
// onAccuracyChanged if it actually disagrees.
|
|
938
|
+
if (previous == true && !isInterference) {
|
|
939
|
+
smoothedSin = Double.NaN
|
|
940
|
+
smoothedCos = Double.NaN
|
|
941
|
+
hasMag = false
|
|
942
|
+
lastEmittedHeading = Double.NaN
|
|
943
|
+
seedFusedYaw = true
|
|
944
|
+
lastRawQuality = preInterferenceRawQuality ?: AccuracyQuality.MEDIUM
|
|
945
|
+
preInterferenceRawQuality = null
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Pump the (possibly restored) raw quality back through
|
|
949
|
+
// fireCalibration so the interference-aware downgrade is applied
|
|
950
|
+
// on the way in, and the post-clear restore propagates out.
|
|
951
|
+
lastRawQuality?.let { fireCalibration(it) }
|
|
952
|
+
refreshSyntheticAccuracy()
|
|
412
953
|
}
|
|
413
954
|
|
|
414
|
-
private fun
|
|
955
|
+
private fun handleMagAccuracyChanged(accuracy: Int) {
|
|
415
956
|
val quality = when (accuracy) {
|
|
416
957
|
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> AccuracyQuality.HIGH
|
|
417
958
|
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> AccuracyQuality.MEDIUM
|
|
418
959
|
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> AccuracyQuality.LOW
|
|
419
960
|
else -> AccuracyQuality.UNRELIABLE
|
|
420
961
|
}
|
|
421
|
-
if (lastAccuracyDeg < 0.0) {
|
|
422
|
-
lastAccuracyDeg = when (quality) {
|
|
423
|
-
AccuracyQuality.HIGH -> 5.0
|
|
424
|
-
AccuracyQuality.MEDIUM -> 15.0
|
|
425
|
-
AccuracyQuality.LOW -> 30.0
|
|
426
|
-
AccuracyQuality.UNRELIABLE -> -1.0
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
962
|
fireCalibration(quality)
|
|
963
|
+
refreshSyntheticAccuracy()
|
|
430
964
|
}
|
|
431
965
|
|
|
432
|
-
private fun
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
966
|
+
private fun smoothHeading(degrees: Double): Double {
|
|
967
|
+
val rad = Math.toRadians(degrees)
|
|
968
|
+
val s = Math.sin(rad)
|
|
969
|
+
val c = Math.cos(rad)
|
|
970
|
+
val ss = smoothedSin
|
|
971
|
+
val cs = smoothedCos
|
|
972
|
+
if (ss.isNaN() || cs.isNaN()) {
|
|
973
|
+
smoothedSin = s
|
|
974
|
+
smoothedCos = c
|
|
975
|
+
return degrees
|
|
439
976
|
}
|
|
977
|
+
val a = smoothingAlpha
|
|
978
|
+
if (a >= 1.0) {
|
|
979
|
+
smoothedSin = s
|
|
980
|
+
smoothedCos = c
|
|
981
|
+
return degrees
|
|
982
|
+
}
|
|
983
|
+
val newSin = a * s + (1.0 - a) * ss
|
|
984
|
+
val newCos = a * c + (1.0 - a) * cs
|
|
985
|
+
smoothedSin = newSin
|
|
986
|
+
smoothedCos = newCos
|
|
987
|
+
var deg = Math.toDegrees(Math.atan2(newSin, newCos))
|
|
988
|
+
if (deg < 0.0) deg += 360.0
|
|
989
|
+
return deg
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private fun degreesFor(quality: AccuracyQuality): Double = when (quality) {
|
|
993
|
+
AccuracyQuality.HIGH -> 5.0
|
|
994
|
+
AccuracyQuality.MEDIUM -> 15.0
|
|
995
|
+
AccuracyQuality.LOW -> 30.0
|
|
996
|
+
AccuracyQuality.UNRELIABLE -> -1.0
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Magnetic interference is a separate signal from magnetometer
|
|
1000
|
+
// calibration — accel can keep its bucket "HIGH" even while the field
|
|
1001
|
+
// is being skewed by external metal/electronics. Reporting
|
|
1002
|
+
// `quality=high` while `interfering=true` is contradictory UX, so we
|
|
1003
|
+
// downgrade the surfaced bucket by one notch when interference is
|
|
1004
|
+
// currently detected.
|
|
1005
|
+
private fun effectiveQuality(raw: AccuracyQuality): AccuracyQuality {
|
|
1006
|
+
if (lastInterference != true) return raw
|
|
1007
|
+
return when (raw) {
|
|
1008
|
+
AccuracyQuality.HIGH -> AccuracyQuality.MEDIUM
|
|
1009
|
+
AccuracyQuality.MEDIUM -> AccuracyQuality.LOW
|
|
1010
|
+
AccuracyQuality.LOW -> AccuracyQuality.UNRELIABLE
|
|
1011
|
+
AccuracyQuality.UNRELIABLE -> AccuracyQuality.UNRELIABLE
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private fun refreshSyntheticAccuracy() {
|
|
1016
|
+
val raw = lastRawQuality ?: return
|
|
1017
|
+
lastAccuracyDeg = degreesFor(effectiveQuality(raw))
|
|
440
1018
|
}
|
|
441
1019
|
|
|
442
1020
|
private fun fireCalibration(quality: AccuracyQuality) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1021
|
+
lastRawQuality = quality
|
|
1022
|
+
val effective = effectiveQuality(quality)
|
|
1023
|
+
if (effective == lastQuality) return
|
|
1024
|
+
lastQuality = effective
|
|
1025
|
+
calibrationCb?.invoke(effective)
|
|
446
1026
|
}
|
|
447
1027
|
|
|
448
1028
|
private fun currentSurfaceRotation(): Int {
|
|
449
1029
|
// Prefer the *activity's* display when available — on foldables and
|
|
450
1030
|
// multi-window setups the activity's display can differ from the
|
|
451
1031
|
// primary display, so reading via DisplayManager.DEFAULT_DISPLAY
|
|
452
|
-
// gives the wrong rotation.
|
|
453
|
-
// fall back to the deprecated WindowManager.defaultDisplay path on
|
|
454
|
-
// older devices, and to DisplayManager when we have no activity
|
|
455
|
-
// (early in the process before any lifecycle callback has fired).
|
|
1032
|
+
// gives the wrong rotation.
|
|
456
1033
|
val activity = currentActivityRef?.get()
|
|
457
1034
|
if (activity != null) {
|
|
458
1035
|
val display: Display? = try {
|
|
@@ -478,4 +1055,40 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
478
1055
|
val diff = ((to - from + 540.0) % 360.0) - 180.0
|
|
479
1056
|
return abs(diff)
|
|
480
1057
|
}
|
|
1058
|
+
|
|
1059
|
+
// Wrap a degree value into [0, 360).
|
|
1060
|
+
private fun wrap360(deg: Double): Double {
|
|
1061
|
+
val m = deg % 360.0
|
|
1062
|
+
return if (m < 0.0) m + 360.0 else m
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Linear-interpolate `a` towards `b` on the circle by `t ∈ [0, 1]`.
|
|
1066
|
+
// Operates on (sin, cos) so it correctly handles the 0/360°
|
|
1067
|
+
// wraparound (e.g. blending 350° and 10° produces 0°, not 180°).
|
|
1068
|
+
// Equivalent to a 2-sample SLERP at this precision.
|
|
1069
|
+
private fun mixYawCircular(a: Double, b: Double, t: Double): Double {
|
|
1070
|
+
val ar = Math.toRadians(a)
|
|
1071
|
+
val br = Math.toRadians(b)
|
|
1072
|
+
val sa = Math.sin(ar); val ca = Math.cos(ar)
|
|
1073
|
+
val sb = Math.sin(br); val cb = Math.cos(br)
|
|
1074
|
+
val s = (1.0 - t) * sa + t * sb
|
|
1075
|
+
val c = (1.0 - t) * ca + t * cb
|
|
1076
|
+
var deg = Math.toDegrees(Math.atan2(s, c))
|
|
1077
|
+
if (deg < 0.0) deg += 360.0
|
|
1078
|
+
return deg
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Linearly interpolate the input low-pass α between `still` (slow
|
|
1082
|
+
// smoothing) and `fast` (light smoothing) based on the gyro-derived
|
|
1083
|
+
// yaw rate. Stationary device → strong filter (kills jitter); rapid
|
|
1084
|
+
// turn → weak filter (avoids lag). Falls back to the `still` value
|
|
1085
|
+
// when the gyro hasn't reported yet.
|
|
1086
|
+
private fun adaptiveInputAlpha(still: Float, fast: Float): Float {
|
|
1087
|
+
if (!hasGameRv) return still
|
|
1088
|
+
val rate = abs(lastYawRateDegPerS)
|
|
1089
|
+
if (rate <= YAW_RATE_STILL_DEG_S) return still
|
|
1090
|
+
if (rate >= YAW_RATE_FAST_DEG_S) return fast
|
|
1091
|
+
val t = ((rate - YAW_RATE_STILL_DEG_S) / (YAW_RATE_FAST_DEG_S - YAW_RATE_STILL_DEG_S)).toFloat()
|
|
1092
|
+
return still + t * (fast - still)
|
|
1093
|
+
}
|
|
481
1094
|
}
|