summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorClaude Agent <agent@example.com>2026-03-25 02:00:17 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:55:58 +0000
commit62c27bf28de30979bc58ef7808185ac189f71197 (patch)
tree1d828faf273022b4fb8d32b73479b9c0a6eb8f81 /android-app/app/src/main
parent0294c6fccc5a1dac7d4fb0ac084b273683e47d32 (diff)
feat(gps): implement NMEA/Android GPS sensor fusion in LocationService
Adds priority-based selection between NMEA GPS (dedicated marine GPS, higher priority) and Android system GPS (fallback) within LocationService. Selection policy: 1. Prefer NMEA when its most recent fix is fresh (≤ nmeaStalenessThresholdMs, default 5 s) 2. Fall back to Android GPS when NMEA is stale 3. Use stale NMEA only when Android has never reported a fix 4. bestPosition is null until at least one source reports New public API: - GpsSource enum (NONE, NMEA, ANDROID) - LocationService.updateNmeaGps(GpsPosition) - LocationService.updateAndroidGps(GpsPosition) - LocationService.bestPosition: StateFlow<GpsPosition?> - LocationService.activeGpsSource: StateFlow<GpsSource> - Injectable clockMs parameter for deterministic unit tests Adds 7 unit tests covering: no-data state, fresh NMEA priority, stale NMEA fallback, only-NMEA/only-Android scenarios, exact-threshold edge case, and NMEA recovery after Android takeover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt87
1 files changed, 85 insertions, 2 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
index c6ff8b7..28dfc90 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
@@ -8,17 +8,37 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
/**
* Aggregates real-time location and environmental sensor data for use throughout
* the safety subsystem (Section 4.6 of COMPONENT_DESIGN.md).
*
- * Call [updateSensorData] whenever new NMEA or Signal K data arrives and
+ * ## GPS sensor fusion
+ * The service accepts fixes from two independent sources:
+ * - **NMEA GPS** — dedicated marine GPS received via [updateNmeaGps] (higher priority)
+ * - **Android GPS** — device built-in location via [updateAndroidGps] (fallback)
+ *
+ * Selection policy (evaluated on every new fix):
+ * 1. Prefer NMEA when its most recent fix is no older than [nmeaStalenessThresholdMs].
+ * 2. Fall back to Android GPS when NMEA is stale.
+ * 3. Use stale NMEA only when Android GPS has never provided a fix.
+ * 4. [bestPosition] is null until at least one source has reported.
+ *
+ * Call [updateSensorData] whenever new NMEA or Signal K sensor data arrives and
* [updateCurrentConditions] when a fresh marine-forecast response is received.
* Use [snapshot] to capture a point-in-time reading at safety-critical moments
* such as MOB activation.
+ *
+ * @param nmeaStalenessThresholdMs Maximum age (ms) of an NMEA fix before it is
+ * considered stale and Android GPS is preferred instead. Default: 5 000 ms.
+ * @param clockMs Injectable clock for unit-testable staleness checks.
*/
class LocationService(
- private val windCalculator: TrueWindCalculator = TrueWindCalculator()
+ private val windCalculator: TrueWindCalculator = TrueWindCalculator(),
+ private val nmeaStalenessThresholdMs: Long = 5_000L,
+ private val clockMs: () -> Long = System::currentTimeMillis
) {
private val _latestSensor = MutableStateFlow<SensorData?>(null)
@@ -32,6 +52,23 @@ class LocationService(
private val _currentSpeedKt = MutableStateFlow<Double?>(null)
private val _currentDirectionDeg = MutableStateFlow<Double?>(null)
+ // ── GPS sensor fusion state ───────────────────────────────────────────────
+
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ /**
+ * The best available GPS fix, selected from NMEA and Android sources according
+ * to the fusion policy described in the class KDoc. Null until at least one
+ * source reports a fix.
+ */
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ /** The source that produced [bestPosition]. [GpsSource.NONE] before any fix arrives. */
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
/**
* Ingest a new sensor reading. If the reading carries apparent wind, boat speed,
* and heading, true wind is resolved immediately via [TrueWindCalculator] and
@@ -54,6 +91,52 @@ class LocationService(
}
}
+ // ── GPS source ingestion ──────────────────────────────────────────────────
+
+ /**
+ * Ingest a new GPS fix from the NMEA source (e.g. a marine chartplotter or
+ * NMEA multiplexer). Triggers a fusion re-evaluation.
+ */
+ fun updateNmeaGps(position: GpsPosition) {
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Ingest a new GPS fix from the Android system location provider.
+ * Triggers a fusion re-evaluation.
+ */
+ fun updateAndroidGps(position: GpsPosition) {
+ lastAndroidPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Selects the best GPS fix and updates [bestPosition] / [activeGpsSource].
+ *
+ * Priority:
+ * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs])
+ * 2. Android GPS (any age)
+ * 3. Stale NMEA (only if Android has never reported)
+ */
+ private fun recomputeBestPosition() {
+ val now = clockMs()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaFresh = nmea != null && (now - nmea.timestampMs) <= nmeaStalenessThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA // stale, but only source
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
/**
* Update the ocean current conditions from the latest marine-forecast response.
*