react-native-nitro-compass 1.1.0 → 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 +141 -20
- package/android/src/main/java/com/margelo/nitro/nitrocompass/HybridNitroCompass.kt +654 -133
- package/ios/HybridNitroCompass.swift +106 -3
- package/lib/commonjs/hook.js +98 -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 +99 -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 +39 -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 +142 -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 +17 -0
- package/nitrogen/generated/android/c++/JHybridNitroCompassSpec.hpp +3 -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 +12 -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 +23 -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 +3 -0
- package/nitrogen/generated/ios/swift/HybridNitroCompassSpec_cxx.swift +34 -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 +3 -0
- package/nitrogen/generated/shared/c++/HybridNitroCompassSpec.hpp +6 -0
- package/nitrogen/generated/shared/c++/SensorKind.hpp +10 -6
- package/package.json +2 -2
- package/src/hook.ts +146 -12
- package/src/index.ts +2 -0
- package/src/multiplex.ts +23 -2
- package/src/specs/NitroCompass.nitro.ts +147 -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
|
|
|
@@ -57,20 +63,74 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
57
63
|
private const val EARTH_FIELD_MIN_UT = 20.0
|
|
58
64
|
private const val EARTH_FIELD_MAX_UT = 70.0
|
|
59
65
|
|
|
60
|
-
// Default low-pass smoothing for the
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// via setSmoothing().
|
|
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().
|
|
68
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
|
|
69
128
|
}
|
|
70
129
|
|
|
71
130
|
@Volatile private var filterDeg: Double = 1.0
|
|
72
131
|
@Volatile private var lastEmittedHeading: Double = Double.NaN
|
|
73
132
|
@Volatile private var lastAccuracyDeg: Double = -1.0
|
|
133
|
+
@Volatile private var lastFieldUt: Double = -1.0
|
|
74
134
|
@Volatile private var lastSample: CompassSample? = null
|
|
75
135
|
@Volatile private var lastQuality: AccuracyQuality? = null
|
|
76
136
|
@Volatile private var declinationDeg: Double = 0.0
|
|
@@ -78,55 +138,127 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
78
138
|
@Volatile private var started: Boolean = false
|
|
79
139
|
@Volatile private var isSubscribed: Boolean = false
|
|
80
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
|
|
81
146
|
@Volatile private var lastInterference: Boolean? = null
|
|
82
147
|
@Volatile private var currentActivityRef: WeakReference<Activity>? = null
|
|
83
148
|
@Volatile private var smoothedSin: Double = Double.NaN
|
|
84
149
|
@Volatile private var smoothedCos: Double = Double.NaN
|
|
85
150
|
@Volatile private var smoothingAlpha: Double = DEFAULT_SMOOTHING_ALPHA
|
|
86
|
-
// Tracks whether the rotation-vector sensor publishes a per-sample
|
|
87
|
-
// accuracy in `event.values[4]`. Many devices don't, in which case the
|
|
88
|
-
// synthetic degree floor derived from SENSOR_STATUS_* is the only
|
|
89
|
-
// accuracy signal available — and we have to keep it updated.
|
|
90
|
-
@Volatile private var hasPerSampleAccuracy: Boolean = false
|
|
91
151
|
// Last raw quality from the OS, before the interference downgrade is
|
|
92
152
|
// applied. Used to re-derive `lastQuality` when interference toggles.
|
|
93
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
|
|
163
|
+
|
|
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
|
|
94
175
|
|
|
95
|
-
|
|
96
|
-
|
|
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)
|
|
97
197
|
private val orientation = FloatArray(3)
|
|
98
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
|
+
|
|
99
226
|
private val epoch = AtomicInteger(0)
|
|
100
227
|
private val activityCounter = AtomicInteger(0)
|
|
101
228
|
private var sensorThread: HandlerThread? = null
|
|
102
229
|
private var sensorHandler: Handler? = null
|
|
103
|
-
private var activeSensor: Sensor? = null
|
|
104
230
|
private var activeListener: SensorEventListener? = null
|
|
105
231
|
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
109
239
|
|
|
110
240
|
private val watchdogHandler = Handler(Looper.getMainLooper())
|
|
111
241
|
private val watchdogRunnable = object : Runnable {
|
|
112
242
|
override fun run() {
|
|
113
243
|
// When the app is backgrounded, the OS legitimately suspends or
|
|
114
244
|
// throttles non-wake-up sensors (Doze on API 23+, background limits
|
|
115
|
-
// on API 26+). Re-registering
|
|
116
|
-
//
|
|
117
|
-
// 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.
|
|
118
247
|
val backgrounded = activityCounter.get() == 0
|
|
119
|
-
val last = lastEventNs
|
|
120
248
|
val now = SystemClock.elapsedRealtimeNanos()
|
|
121
|
-
|
|
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)) {
|
|
122
256
|
synchronized(this@HybridNitroCompass) {
|
|
123
257
|
if (started && isSubscribed) {
|
|
124
258
|
unsubscribeLocked()
|
|
125
259
|
subscribeLocked()
|
|
126
|
-
// subscribeLocked() re-arms the watchdog itself, so don't
|
|
127
|
-
// double-post below. Reset the timestamp to give the fresh
|
|
128
|
-
// subscription a full window before being judged stale.
|
|
129
260
|
lastEventNs = SystemClock.elapsedRealtimeNanos()
|
|
261
|
+
lastGameRvEventNs = 0L
|
|
130
262
|
}
|
|
131
263
|
}
|
|
132
264
|
return
|
|
@@ -145,15 +277,18 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
145
277
|
synchronized(this) {
|
|
146
278
|
stopLocked()
|
|
147
279
|
started = true
|
|
148
|
-
|
|
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
|
|
149
284
|
this.onHeading = onHeading
|
|
150
285
|
lastEmittedHeading = Double.NaN
|
|
151
286
|
lastAccuracyDeg = -1.0
|
|
287
|
+
lastFieldUt = -1.0
|
|
152
288
|
lastSample = null
|
|
153
289
|
lastQuality = null
|
|
154
|
-
|
|
155
|
-
hasPerSampleAccuracy = false
|
|
156
290
|
lastRawQuality = null
|
|
291
|
+
|
|
157
292
|
registerLifecycleCallbacks()
|
|
158
293
|
subscribeLocked()
|
|
159
294
|
}
|
|
@@ -168,38 +303,87 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
168
303
|
override fun hasCompass(): Boolean {
|
|
169
304
|
val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
170
305
|
?: return false
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
173
309
|
}
|
|
174
310
|
|
|
175
311
|
override fun isStarted(): Boolean = started
|
|
176
312
|
|
|
177
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
|
|
178
318
|
filterDeg = degrees.coerceAtLeast(0.0)
|
|
179
319
|
}
|
|
180
320
|
|
|
181
321
|
override fun setSmoothing(alpha: Double) {
|
|
182
|
-
|
|
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)
|
|
183
328
|
}
|
|
184
329
|
|
|
185
330
|
override fun getDiagnostics(): SensorDiagnostics? {
|
|
186
331
|
val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
187
332
|
?: return null
|
|
188
|
-
return
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
194
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
|
+
)
|
|
195
356
|
}
|
|
196
357
|
|
|
197
358
|
override fun getCurrentHeading(): CompassSample? = lastSample
|
|
198
359
|
|
|
199
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
|
|
200
364
|
declinationDeg = degrees
|
|
201
365
|
}
|
|
202
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
|
+
|
|
203
387
|
override fun setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) -> Unit) {
|
|
204
388
|
calibrationCb = onChange
|
|
205
389
|
}
|
|
@@ -223,6 +407,46 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
223
407
|
}
|
|
224
408
|
}
|
|
225
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
|
+
|
|
226
450
|
// Sensors don't require a runtime permission on Android, so both
|
|
227
451
|
// permission methods are unconditionally granted.
|
|
228
452
|
override fun getPermissionStatus(): PermissionStatus = PermissionStatus.GRANTED
|
|
@@ -241,15 +465,27 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
241
465
|
lastSample = null
|
|
242
466
|
lastQuality = null
|
|
243
467
|
lastInterference = null
|
|
468
|
+
preInterferenceRawQuality = null
|
|
244
469
|
}
|
|
245
470
|
|
|
246
471
|
private fun subscribeLocked() {
|
|
247
472
|
if (isSubscribed) return
|
|
248
473
|
val sm = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
249
474
|
?: throw IllegalStateException("SensorManager unavailable")
|
|
250
|
-
val
|
|
251
|
-
?:
|
|
252
|
-
|
|
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
|
|
253
489
|
|
|
254
490
|
val myEpoch = epoch.incrementAndGet()
|
|
255
491
|
val thread = HandlerThread("NitroCompass-Sensor").also { it.start() }
|
|
@@ -262,28 +498,50 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
262
498
|
|
|
263
499
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
|
264
500
|
if (myEpoch != epoch.get()) return
|
|
265
|
-
|
|
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
|
+
}
|
|
266
511
|
}
|
|
267
512
|
}
|
|
268
|
-
sm.registerListener(listener,
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
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)
|
|
276
524
|
}
|
|
277
525
|
|
|
278
526
|
sensorThread = thread
|
|
279
527
|
sensorHandler = handler
|
|
280
|
-
activeSensor = sensor
|
|
281
528
|
activeListener = listener
|
|
282
529
|
isSubscribed = true
|
|
283
530
|
|
|
531
|
+
hasAccel = false
|
|
532
|
+
hasMag = false
|
|
533
|
+
hasBias = false
|
|
534
|
+
lastBiasJumpNs = 0L
|
|
284
535
|
smoothedSin = Double.NaN
|
|
285
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
|
|
286
543
|
lastEventNs = 0L
|
|
544
|
+
lastGameRvEventNs = 0L
|
|
287
545
|
watchdogHandler.removeCallbacks(watchdogRunnable)
|
|
288
546
|
watchdogHandler.postDelayed(watchdogRunnable, WATCHDOG_PERIOD_MS)
|
|
289
547
|
}
|
|
@@ -294,7 +552,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
294
552
|
sensorThread?.quitSafely()
|
|
295
553
|
sensorThread = null
|
|
296
554
|
sensorHandler = null
|
|
297
|
-
activeSensor = null
|
|
298
555
|
activeListener = null
|
|
299
556
|
return
|
|
300
557
|
}
|
|
@@ -302,7 +559,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
302
559
|
val sm = NitroModules.applicationContext?.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
|
303
560
|
activeListener?.let { sm?.unregisterListener(it) }
|
|
304
561
|
activeListener = null
|
|
305
|
-
activeSensor = null
|
|
306
562
|
sensorHandler = null
|
|
307
563
|
sensorThread?.quitSafely()
|
|
308
564
|
sensorThread = null
|
|
@@ -312,10 +568,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
312
568
|
private fun registerLifecycleCallbacks() {
|
|
313
569
|
if (lifecycleCallbacks != null) return
|
|
314
570
|
val app = NitroModules.applicationContext?.applicationContext as? Application ?: return
|
|
315
|
-
// start() can be called from a headless / background context (e.g. a
|
|
316
|
-
// headless JS task, or before any Activity has come up). Don't assume
|
|
317
|
-
// foreground — query the OS so pauseOnBackground=true actually keeps
|
|
318
|
-
// the sensor unsubscribed when the app isn't user-visible.
|
|
319
571
|
activityCounter.set(if (isAppInForeground(app)) 1 else 0)
|
|
320
572
|
val cb = object : Application.ActivityLifecycleCallbacks {
|
|
321
573
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
|
@@ -385,18 +637,181 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
385
637
|
}
|
|
386
638
|
|
|
387
639
|
private fun handleSensorEvent(event: SensorEvent) {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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()
|
|
713
|
+
|
|
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)
|
|
396
725
|
|
|
397
|
-
|
|
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
|
+
}
|
|
398
803
|
|
|
399
|
-
|
|
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
|
|
400
815
|
|
|
401
816
|
val (axisX, axisY) = when (currentSurfaceRotation()) {
|
|
402
817
|
Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X
|
|
@@ -404,53 +819,140 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
404
819
|
Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X
|
|
405
820
|
else -> SensorManager.AXIS_X to SensorManager.AXIS_Y
|
|
406
821
|
}
|
|
407
|
-
SensorManager.remapCoordinateSystem(
|
|
822
|
+
if (!SensorManager.remapCoordinateSystem(
|
|
823
|
+
rotationMatrix, axisX, axisY, remappedMatrix
|
|
824
|
+
)
|
|
825
|
+
) return
|
|
408
826
|
SensorManager.getOrientation(remappedMatrix, orientation)
|
|
409
827
|
|
|
410
|
-
var
|
|
411
|
-
if (
|
|
412
|
-
|
|
828
|
+
var magYawDeg = Math.toDegrees(orientation[0].toDouble())
|
|
829
|
+
if (magYawDeg.isNaN()) return
|
|
830
|
+
if (magYawDeg < 0.0) magYawDeg += 360.0
|
|
413
831
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
419
861
|
}
|
|
420
862
|
|
|
863
|
+
val smoothed = smoothHeading(headingDeg)
|
|
864
|
+
|
|
421
865
|
val prev = lastEmittedHeading
|
|
422
|
-
val delta = if (prev.isNaN()) Double.MAX_VALUE else shortestArc(prev,
|
|
866
|
+
val delta = if (prev.isNaN()) Double.MAX_VALUE else shortestArc(prev, smoothed)
|
|
423
867
|
if (filterDeg > 0.0 && delta < filterDeg) return
|
|
424
|
-
lastEmittedHeading =
|
|
868
|
+
lastEmittedHeading = smoothed
|
|
425
869
|
|
|
426
|
-
var emitted =
|
|
870
|
+
var emitted = smoothed + declinationDeg
|
|
427
871
|
emitted = ((emitted % 360.0) + 360.0) % 360.0
|
|
428
|
-
val sample = CompassSample(emitted, lastAccuracyDeg)
|
|
872
|
+
val sample = CompassSample(emitted, lastAccuracyDeg, lastFieldUt)
|
|
429
873
|
lastSample = sample
|
|
430
874
|
onHeading?.invoke(sample)
|
|
431
875
|
}
|
|
432
876
|
|
|
433
|
-
private fun
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
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
|
|
441
905
|
lastInterference = isInterference
|
|
442
906
|
interferenceCb?.invoke(isInterference)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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.
|
|
449
951
|
lastRawQuality?.let { fireCalibration(it) }
|
|
450
|
-
|
|
952
|
+
refreshSyntheticAccuracy()
|
|
451
953
|
}
|
|
452
954
|
|
|
453
|
-
private fun
|
|
955
|
+
private fun handleMagAccuracyChanged(accuracy: Int) {
|
|
454
956
|
val quality = when (accuracy) {
|
|
455
957
|
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> AccuracyQuality.HIGH
|
|
456
958
|
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> AccuracyQuality.MEDIUM
|
|
@@ -458,7 +960,7 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
458
960
|
else -> AccuracyQuality.UNRELIABLE
|
|
459
961
|
}
|
|
460
962
|
fireCalibration(quality)
|
|
461
|
-
|
|
963
|
+
refreshSyntheticAccuracy()
|
|
462
964
|
}
|
|
463
965
|
|
|
464
966
|
private fun smoothHeading(degrees: Double): Double {
|
|
@@ -487,16 +989,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
487
989
|
return deg
|
|
488
990
|
}
|
|
489
991
|
|
|
490
|
-
private fun qualityFor(accuracyDeg: Double): AccuracyQuality {
|
|
491
|
-
return when {
|
|
492
|
-
accuracyDeg < 0 -> AccuracyQuality.UNRELIABLE
|
|
493
|
-
accuracyDeg < 5 -> AccuracyQuality.HIGH
|
|
494
|
-
accuracyDeg < 15 -> AccuracyQuality.MEDIUM
|
|
495
|
-
accuracyDeg < 30 -> AccuracyQuality.LOW
|
|
496
|
-
else -> AccuracyQuality.UNRELIABLE
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
992
|
private fun degreesFor(quality: AccuracyQuality): Double = when (quality) {
|
|
501
993
|
AccuracyQuality.HIGH -> 5.0
|
|
502
994
|
AccuracyQuality.MEDIUM -> 15.0
|
|
@@ -504,12 +996,12 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
504
996
|
AccuracyQuality.UNRELIABLE -> -1.0
|
|
505
997
|
}
|
|
506
998
|
|
|
507
|
-
// Magnetic interference is a separate signal from
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
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.
|
|
513
1005
|
private fun effectiveQuality(raw: AccuracyQuality): AccuracyQuality {
|
|
514
1006
|
if (lastInterference != true) return raw
|
|
515
1007
|
return when (raw) {
|
|
@@ -520,10 +1012,6 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
520
1012
|
}
|
|
521
1013
|
}
|
|
522
1014
|
|
|
523
|
-
// For devices that don't publish a per-sample accuracy in
|
|
524
|
-
// event.values[4], the only accuracy signal is the OS bucket. Map the
|
|
525
|
-
// current effective bucket back to a representative degree value so
|
|
526
|
-
// CompassSample.accuracy reflects interference too.
|
|
527
1015
|
private fun refreshSyntheticAccuracy() {
|
|
528
1016
|
val raw = lastRawQuality ?: return
|
|
529
1017
|
lastAccuracyDeg = degreesFor(effectiveQuality(raw))
|
|
@@ -541,10 +1029,7 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
541
1029
|
// Prefer the *activity's* display when available — on foldables and
|
|
542
1030
|
// multi-window setups the activity's display can differ from the
|
|
543
1031
|
// primary display, so reading via DisplayManager.DEFAULT_DISPLAY
|
|
544
|
-
// gives the wrong rotation.
|
|
545
|
-
// fall back to the deprecated WindowManager.defaultDisplay path on
|
|
546
|
-
// older devices, and to DisplayManager when we have no activity
|
|
547
|
-
// (early in the process before any lifecycle callback has fired).
|
|
1032
|
+
// gives the wrong rotation.
|
|
548
1033
|
val activity = currentActivityRef?.get()
|
|
549
1034
|
if (activity != null) {
|
|
550
1035
|
val display: Display? = try {
|
|
@@ -570,4 +1055,40 @@ class HybridNitroCompass : HybridNitroCompassSpec() {
|
|
|
570
1055
|
val diff = ((to - from + 540.0) % 360.0) - 180.0
|
|
571
1056
|
return abs(diff)
|
|
572
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
|
+
}
|
|
573
1094
|
}
|