package com.example.androidapp.gps import com.example.androidapp.data.model.SensorData import com.example.androidapp.wind.TrueWindCalculator import com.example.androidapp.wind.ApparentWind import com.example.androidapp.wind.TrueWindData 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). * * ## 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. When NMEA is marginally stale (older than [nmeaStalenessThresholdMs] but within * [nmeaExtendedThresholdMs]) **and** Android GPS is also available, compare * [GpsPosition.accuracyMeters]: keep NMEA if its reported accuracy is strictly better * (lower metres). Fall back to Android when accuracy is unavailable or Android wins. * 3. Fall back to Android GPS when NMEA is very stale (beyond [nmeaExtendedThresholdMs]). * 4. Use stale NMEA only when Android GPS has never provided a fix. * 5. [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 enters the * quality-comparison zone. Default: 5 000 ms. * @param nmeaExtendedThresholdMs Maximum age (ms) up to which a marginally-stale NMEA fix * can still win over Android if its [GpsPosition.accuracyMeters] is strictly better. * Must be ≥ [nmeaStalenessThresholdMs]. Default: 10 000 ms. * @param clockMs Injectable clock for unit-testable staleness checks. */ class LocationService( private val windCalculator: TrueWindCalculator = TrueWindCalculator(), private val nmeaStalenessThresholdMs: Long = 5_000L, private val nmeaExtendedThresholdMs: Long = 10_000L, private val clockMs: () -> Long = System::currentTimeMillis ) { private val _latestSensor = MutableStateFlow(null) /** The most recently received unified sensor reading. */ val latestSensor: StateFlow = _latestSensor.asStateFlow() private val _latestTrueWind = MutableStateFlow(null) /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */ val latestTrueWind: StateFlow = _latestTrueWind.asStateFlow() private val _currentSpeedKt = MutableStateFlow(null) private val _currentDirectionDeg = MutableStateFlow(null) // ── GPS sensor fusion state ─────────────────────────────────────────────── private var lastNmeaPosition: GpsPosition? = null private var lastAndroidPosition: GpsPosition? = null private val _bestPosition = MutableStateFlow(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 = _bestPosition.asStateFlow() private val _activeGpsSource = MutableStateFlow(GpsSource.NONE) /** The source that produced [bestPosition]. [GpsSource.NONE] before any fix arrives. */ val activeGpsSource: StateFlow = _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 * stored in [latestTrueWind]. */ fun updateSensorData(data: SensorData) { _latestSensor.value = data val aws = data.apparentWindSpeedKt val awa = data.apparentWindAngleDeg val bsp = data.speedOverGroundKt // use SOG as proxy when BSP is absent val hdg = data.headingTrueDeg if (aws != null && awa != null && bsp != null && hdg != null) { _latestTrueWind.value = windCalculator.update( apparent = ApparentWind(speedKt = aws, angleDeg = awa), bsp = bsp, hdgDeg = hdg ) } } // ── 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 tiers (in order): * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs]) — always preferred. * 2. Marginally-stale NMEA (age in (primary, extended] threshold) when Android is * also available — keep NMEA only if its [GpsPosition.accuracyMeters] is strictly * better than Android's; otherwise use Android. * 3. Android GPS (any age) once NMEA is beyond the extended threshold. * 4. Stale NMEA — used as last resort when Android has never reported. */ private fun recomputeBestPosition() { val now = clockMs() val nmea = lastNmeaPosition val android = lastAndroidPosition val nmeaAge = nmea?.let { now - it.timestampMs } val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs val nmeaMarginallyStale = nmeaAge != null && nmeaAge > nmeaStalenessThresholdMs && nmeaAge <= nmeaExtendedThresholdMs val (best, source) = when { nmeaFresh -> nmea!! to GpsSource.NMEA nmeaMarginallyStale && android != null -> // Quality tie-break: NMEA wins only when it has a strictly better accuracy. if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA else android to GpsSource.ANDROID android != null -> android to GpsSource.ANDROID nmea != null -> nmea to GpsSource.NMEA // only source, however stale else -> null to GpsSource.NONE } _bestPosition.value = best _activeGpsSource.value = source } // ── private helpers ─────────────────────────────────────────────────────── /** * Returns true when this fix carries an accuracy estimate that is numerically * smaller (i.e. better) than [other]'s. Returns false when either estimate is * absent — conservatively preferring the other source when quality is unknown. */ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean { val thisAccuracy = accuracyMeters ?: return false val otherAccuracy = other.accuracyMeters ?: return true return thisAccuracy < otherAccuracy } /** * Update the ocean current conditions from the latest marine-forecast response. * * @param speedKt Current speed in knots (null to clear) * @param directionDeg Direction the current flows TOWARD, in degrees (null to clear) */ fun updateCurrentConditions(speedKt: Double?, directionDeg: Double?) { _currentSpeedKt.value = speedKt _currentDirectionDeg.value = directionDeg } /** * Captures a snapshot of wind and current conditions at the current moment. * * All fields are nullable — only data that was available at snapshot time is * populated. This snapshot is intended to be logged alongside a [MobEvent] * at the instant of MOB activation. */ fun snapshot(): EnvironmentalSnapshot { val trueWind = _latestTrueWind.value return EnvironmentalSnapshot( windSpeedKt = trueWind?.speedKt, windDirectionDeg = trueWind?.directionDeg, currentSpeedKt = _currentSpeedKt.value, currentDirectionDeg = _currentDirectionDeg.value ) } } /** * Point-in-time snapshot of wind and current conditions. * * @param windSpeedKt True Wind Speed in knots; null if sensors were unavailable. * @param windDirectionDeg True Wind Direction (degrees true, wind comes FROM); null if unavailable. * @param currentSpeedKt Ocean current speed in knots; null if forecast was unavailable. * @param currentDirectionDeg Ocean current direction (degrees, flows TOWARD); null if unavailable. */ data class EnvironmentalSnapshot( val windSpeedKt: Double?, val windDirectionDeg: Double?, val currentSpeedKt: Double?, val currentDirectionDeg: Double? )