summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt87
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt110
2 files changed, 195 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.
*
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
index d9192c6..237004b 100644
--- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
@@ -114,4 +114,114 @@ class LocationServiceTest {
assertEquals(data, svc.latestSensor.first())
}
+
+ // ── GPS sensor fusion ─────────────────────────────────────────────────────
+
+ private fun fusionService(
+ nmeaStalenessThresholdMs: Long = 5_000L,
+ clockMs: () -> Long = System::currentTimeMillis
+ ) = LocationService(
+ nmeaStalenessThresholdMs = nmeaStalenessThresholdMs,
+ clockMs = clockMs
+ )
+
+ private fun pos(lat: Double, lon: Double, timestampMs: Long) =
+ GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs)
+
+ @Test
+ fun noGpsData_bestPositionNullAndSourceNone() = runBlocking {
+ val svc = fusionService()
+ assertNull(svc.bestPosition.first())
+ assertEquals(GpsSource.NONE, svc.activeGpsSource.first())
+ }
+
+ @Test
+ fun freshNmea_preferredOverAndroid() = runBlocking {
+ val now = 10_000L
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, now)
+ val androidFix = pos(42.0, -72.0, now - 1_000L)
+
+ svc.updateAndroidGps(androidFix)
+ svc.updateNmeaGps(nmeaFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun staleNmea_androidFallback() = runBlocking {
+ val nmeaTime = 0L
+ val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s)
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, nmeaTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking {
+ val now = 60_000L // 60 s after fix — very stale
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, 0L)
+ svc.updateNmeaGps(nmeaFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun onlyAndroidAvailable_isUsed() = runBlocking {
+ val svc = fusionService()
+ val androidFix = pos(42.0, -72.0, System.currentTimeMillis())
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking {
+ val fixTime = 0L
+ val now = 5_000L // exactly at threshold
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, fixTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ }
+
+ @Test
+ fun nmeaRecovery_switchesBackFromAndroid() = runBlocking {
+ var now = 0L
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ // Fresh NMEA
+ svc.updateNmeaGps(pos(41.0, -71.0, 0L))
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
+
+ // NMEA goes stale; Android takes over
+ now = 10_000L
+ val androidFix = pos(42.0, -72.0, 10_000L)
+ svc.updateAndroidGps(androidFix)
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value)
+
+ // NMEA recovers with a fresh fix
+ val freshNmea = pos(41.1, -71.1, 10_000L)
+ svc.updateNmeaGps(freshNmea)
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
+ assertEquals(freshNmea, svc.bestPosition.value)
+ }
}