diff options
Diffstat (limited to 'android-app/app/src/main')
27 files changed, 232 insertions, 717 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt deleted file mode 100644 index d6f685a..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.androidapp.data.storage - -import org.terst.nav.data.model.GribFile -import org.terst.nav.data.model.GribRegion -import java.time.Instant - -interface GribFileManager { - fun saveMetadata(file: GribFile) - fun listFiles(region: GribRegion): List<GribFile> - fun latestFile(region: GribRegion): GribFile? - fun delete(file: GribFile): Boolean - fun purgeOlderThan(before: Instant): Int - fun totalSizeBytes(): Long -} - -class InMemoryGribFileManager : GribFileManager { - private val files = mutableListOf<GribFile>() - override fun saveMetadata(file: GribFile) { files.add(file) } - override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt } - override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull() - override fun delete(file: GribFile): Boolean = files.remove(file) - override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size } - override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes } -} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt deleted file mode 100644 index cbe5c84..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.androidapp.gps - -data class GpsPosition( - val latitude: Double, // degrees, positive = North - val longitude: Double, // degrees, positive = East - val sog: Double, // Speed Over Ground in knots - val cog: Double, // Course Over Ground in degrees true (0-360) - val timestampMs: Long, // Unix millis UTC - val accuracyMeters: Double? = null // estimated horizontal accuracy (1-sigma); null = unknown -) 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 deleted file mode 100644 index 0a315d4..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt +++ /dev/null @@ -1,216 +0,0 @@ -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<SensorData?>(null) - /** The most recently received unified sensor reading. */ - val latestSensor: StateFlow<SensorData?> = _latestSensor.asStateFlow() - - private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null) - /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */ - val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow() - - 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 - * 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? -) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt deleted file mode 100644 index b1b186a..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.example.androidapp.nmea - -import com.example.androidapp.gps.GpsPosition -import java.util.Calendar -import java.util.TimeZone - -class NmeaParser { - - /** - * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the - * sentence is void (status=V), malformed, or cannot be parsed. - * - * Supported talker IDs: GP, GN, and any other standard prefix. - * SOG and COG default to 0.0 when the fields are absent. - */ - fun parseRmc(sentence: String): GpsPosition? { - if (sentence.isBlank()) return null - - val body = if ('*' in sentence) sentence.substringBefore('*') else sentence - val fields = body.split(',') - if (fields.size < 10) return null - - if (!fields[0].endsWith("RMC")) return null - if (fields[2] != "A") return null - - val latStr = fields.getOrNull(3) ?: return null - val latDir = fields.getOrNull(4) ?: return null - val lonStr = fields.getOrNull(5) ?: return null - val lonDir = fields.getOrNull(6) ?: return null - - val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0 - val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0 - - val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0 - val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0 - - val timestampMs = parseTimestamp( - timeStr = fields.getOrNull(1) ?: "", - dateStr = fields.getOrNull(9) ?: "" - ) - if (timestampMs == 0L) return null - - return GpsPosition(latitude, longitude, sog, cog, timestampMs) - } - - /** - * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees. - */ - private fun parseNmeaDegrees(value: String): Double { - val raw = value.toDoubleOrNull() ?: return 0.0 - val degrees = (raw / 100.0).toInt() - val minutes = raw - degrees * 100.0 - return degrees + minutes / 60.0 - } - - /** - * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into Unix epoch millis UTC. - * Returns 0 on any parse failure. - */ - private fun parseTimestamp(timeStr: String, dateStr: String): Long { - return try { - val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - cal.isLenient = false - - if (dateStr.length >= 6) { - val day = dateStr.substring(0, 2).toInt() - val month = dateStr.substring(2, 4).toInt() - 1 - val yy = dateStr.substring(4, 6).toInt() - val year = if (yy < 70) 2000 + yy else 1900 + yy - cal.set(Calendar.YEAR, year) - cal.set(Calendar.MONTH, month) - cal.set(Calendar.DAY_OF_MONTH, day) - } - - if (timeStr.length >= 6) { - val hours = timeStr.substring(0, 2).toInt() - val minutes = timeStr.substring(2, 4).toInt() - val seconds = timeStr.substring(4, 6).toInt() - val millis = if (timeStr.length > 7) { - val fracStr = timeStr.substring(7) - (("0.$fracStr").toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0 - } else 0 - cal.set(Calendar.HOUR_OF_DAY, hours) - cal.set(Calendar.MINUTE, minutes) - cal.set(Calendar.SECOND, seconds) - cal.set(Calendar.MILLISECOND, millis) - } - - cal.timeInMillis - } catch (e: Exception) { - 0L - } - } -} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt deleted file mode 100644 index f544f63..0000000 --- a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.androidapp.safety - -import kotlin.math.sqrt - -/** - * Holds UI-facing state for the anchor watch setup screen and provides - * the suggested watch-circle radius derived from depth and rode out. - */ -class AnchorWatchState { - - /** - * Returns the recommended watch-circle radius (metres) for the given depth - * and amount of rode deployed. - * - * Uses the Pythagorean formula sqrt(rode² - vertical²) when the geometry is - * valid (rode > depth + freeboard). Falls back to [rodeOutM] itself as the - * maximum possible swing radius when the rode is too short to form a catenary angle. - */ - fun calculateRecommendedWatchCircleRadius(depthM: Double, rodeOutM: Double): Double { - val vertical = depthM + 2.0 // 2 m default freeboard - return if (rodeOutM > vertical) sqrt(rodeOutM * rodeOutM - vertical * vertical) - else rodeOutM - } -} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt deleted file mode 100644 index 0c63662..0000000 --- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.terst.nav - -import android.location.Location -import kotlin.math.* - -data class AnchorWatchState( - val anchorLocation: Location? = null, - val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS, - val setTimeMillis: Long = 0L, - val isActive: Boolean = false -) { - companion object { - const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters - - /** - * Calculates the recommended watch circle radius based on depth, freeboard, and rode out. - * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out)) - * - * @param depthMeters Depth from surface to seabed in meters. - * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters. - * @param rodeOutMeters Length of chain/rode deployed in meters. - * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid. - */ - fun calculateRecommendedWatchCircleRadius( - depthMeters: Double, - freeboardMeters: Double, - rodeOutMeters: Double - ): Double { - if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) { - return 0.0 // Invalid inputs - } - - val totalVerticalDistance = depthMeters + freeboardMeters - - // Ensure we don't take asin of a value > 1 or < -1 - if (totalVerticalDistance > rodeOutMeters) { - // Rode is too short for the depth+freeboard, effectively boat is directly above anchor - // In this case, the watch circle radius is 0, or very small. - return 0.0 - } - - // angle = asin( (Depth + Freeboard) / Rode Out ) - val angle = asin(totalVerticalDistance / rodeOutMeters) - - // Watch circle radius = Rode Out * cos(angle) - return rodeOutMeters * cos(angle) - } - } - - fun isDragging(currentLocation: Location): Boolean { - anchorLocation ?: return false // Cannot drag if anchor not set - if (!isActive) return false // Not active, so not dragging - - val distance = anchorLocation.distanceTo(currentLocation) - return distance > watchCircleRadiusMeters - } -} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt index 138fc6c..b18db8d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -20,12 +20,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.asStateFlow import org.terst.nav.nmea.NmeaParser import org.terst.nav.nmea.NmeaStreamManager import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData import org.terst.nav.gps.GpsPosition +import org.terst.nav.data.model.SensorData +import org.terst.nav.safety.AnchorWatchState +import org.terst.nav.wind.TrueWindCalculator +import org.terst.nav.wind.ApparentWind +import org.terst.nav.wind.TrueWindData import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.tasks.await import kotlinx.coroutines.CoroutineScope @@ -34,22 +40,23 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -data class GpsData( - val latitude: Double, - val longitude: Double, - val speedOverGround: Float, // m/s - val courseOverGround: Float // degrees -) { - fun toLocation(): Location { - val location = Location("GpsData") - location.latitude = latitude - location.longitude = longitude - location.speed = speedOverGround - location.bearing = courseOverGround - return location - } -} - +/** Source of the currently active GPS fix. */ +enum class GpsSource { NONE, NMEA, ANDROID } + +/** + * Point-in-time snapshot of wind and current conditions. + */ +data class EnvironmentalSnapshot( + val windSpeedKt: Double?, + val windDirectionDeg: Double?, + val currentSpeedKt: Double?, + val currentDirectionDeg: Double? +) + +/** + * Aggregates real-time location and environmental sensor data for use throughout + * the navigation subsystem. + */ class LocationService : Service() { private lateinit var fusedLocationClient: FusedLocationProviderClient @@ -60,22 +67,30 @@ class LocationService : Service() { private lateinit var nmeaStreamManager: NmeaStreamManager private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val windCalculator = TrueWindCalculator() + + // GPS sensor fusion state + private var lastNmeaPosition: GpsPosition? = null + private var lastAndroidPosition: GpsPosition? = null + private val nmeaStalenessThresholdMs: Long = 5_000L + private val nmeaExtendedThresholdMs: Long = 10_000L + private val NOTIFICATION_CHANNEL_ID = "location_service_channel" private val NOTIFICATION_ID = 123 - private var isAlarmTriggered = false // To prevent repeated alarm triggering + private var isAlarmTriggered = false override fun onCreate() { super.onCreate() Log.d("LocationService", "Service created") fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) - anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context + anchorAlarmManager = AnchorAlarmManager(this) barometerSensorManager = BarometerSensorManager(this) nmeaParser = NmeaParser() nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope) createNotificationChannel() - // Observe barometer status and update our public state + // Observe barometer status serviceScope.launch { barometerSensorManager.barometerStatus.collect { status -> _barometerStatus.value = status @@ -84,14 +99,17 @@ class LocationService : Service() { // Collect NMEA GPS positions serviceScope.launch { - nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition -> - _nmeaGpsPositionFlow.emit(gpsPosition) + nmeaStreamManager.nmeaGpsPosition.collectLatest { position -> + lastNmeaPosition = position + recomputeBestPosition() + _nmeaGpsPositionFlow.emit(position) } } // Collect NMEA Wind Data serviceScope.launch { nmeaStreamManager.nmeaWindData.collectLatest { windData -> + updateTrueWindFromNmea(windData) _nmeaWindDataFlow.emit(windData) } } @@ -110,26 +128,22 @@ class LocationService : Service() { } } - // Mock tidal current data generator - serviceScope.launch { - while (true) { - val currents = MockTidalCurrentGenerator.generateMockCurrents() - _tidalCurrentState.update { it.copy(currents = currents) } - kotlinx.coroutines.delay(60000) // Update every minute - } - } - locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> - val gpsData = GpsData( + val position = GpsPosition( latitude = location.latitude, longitude = location.longitude, - speedOverGround = location.speed, - courseOverGround = location.bearing + sog = location.speed * 1.94384, // m/s to knots + cog = location.bearing.toDouble(), + timestampMs = location.time, + accuracyMeters = if (location.hasAccuracy()) location.accuracy.toDouble() else null ) + lastAndroidPosition = position + recomputeBestPosition() + serviceScope.launch { - _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS) + _locationFlow.emit(position) } // Check for anchor drag if anchor watch is active @@ -139,32 +153,71 @@ class LocationService : Service() { } } - /** - * Checks if the current location is outside the anchor watch circle. - */ + private fun recomputeBestPosition() { + val now = System.currentTimeMillis() + 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 -> + 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 + else -> null to GpsSource.NONE + } + + _bestPosition.value = best + _activeGpsSource.value = source + } + + private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean { + val thisAccuracy = accuracyMeters ?: return false + val otherAccuracy = other.accuracyMeters ?: return true + return thisAccuracy < otherAccuracy + } + + private fun updateTrueWindFromNmea(wind: WindData) { + val sog = _bestPosition.value?.sog + val hdg = _nmeaHeadingDataFlow.replayCache.firstOrNull()?.headingDegreesTrue + + if (sog != null && hdg != null) { + _latestTrueWind.value = windCalculator.update( + apparent = ApparentWind(speedKt = wind.windSpeed, angleDeg = wind.windAngle), + bsp = sog, // Use SOG as proxy for BSP if BSP is not available + hdgDeg = hdg + ) + } + } + private fun checkAnchorDrag(location: Location) { _anchorWatchState.update { currentState -> if (currentState.isActive && currentState.anchorLocation != null) { val isDragging = currentState.isDragging(location) if (isDragging) { - Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m") + Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!!") if (!isAlarmTriggered) { anchorAlarmManager.startAlarm() isAlarmTriggered = true } } else { - Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m") if (isAlarmTriggered) { anchorAlarmManager.stopAlarm() isAlarmTriggered = false } } - } else { - // If anchor watch is not active, ensure alarm is stopped - if (isAlarmTriggered) { - anchorAlarmManager.stopAlarm() - isAlarmTriggered = false - } + } else if (isAlarmTriggered) { + anchorAlarmManager.stopAlarm() + isAlarmTriggered = false } currentState } @@ -173,24 +226,21 @@ class LocationService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START_FOREGROUND_SERVICE -> { - Log.d("LocationService", "Starting foreground service") startForeground(NOTIFICATION_ID, createNotification()) serviceScope.launch { - _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL + _currentPowerMode.emit(PowerMode.FULL) startLocationUpdatesInternal(PowerMode.FULL) } barometerSensorManager.start() nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT) } ACTION_STOP_FOREGROUND_SERVICE -> { - Log.d("LocationService", "Stopping foreground service") stopLocationUpdatesInternal() barometerSensorManager.stop() nmeaStreamManager.stop() stopSelf() } ACTION_START_ANCHOR_WATCH -> { - Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH") val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) serviceScope.launch { startAnchorWatch(radius) @@ -198,45 +248,34 @@ class LocationService : Service() { } } ACTION_STOP_ANCHOR_WATCH -> { - Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH") stopAnchorWatch() - setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch + setPowerMode(PowerMode.FULL) } ACTION_UPDATE_WATCH_RADIUS -> { - Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS") val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) updateWatchCircleRadius(radius) } - ACTION_TOGGLE_TIDAL_VISIBILITY -> { - val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false) - _tidalCurrentState.update { it.copy(isVisible = isVisible) } - } } return START_NOT_STICKY } - override fun onBind(intent: Intent?): IBinder? { - return null // Not a bound service - } + override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { super.onDestroy() - Log.d("LocationService", "Service destroyed") stopLocationUpdatesInternal() anchorAlarmManager.stopAlarm() barometerSensorManager.stop() - nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed + nmeaStreamManager.stop() _anchorWatchState.value = AnchorWatchState(isActive = false) - isAlarmTriggered = false // Reset alarm trigger state - serviceScope.cancel() // Cancel the coroutine scope + isAlarmTriggered = false + serviceScope.cancel() } - @SuppressLint("MissingPermission") private fun startLocationUpdatesInternal(powerMode: PowerMode) { - Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms") val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis) - .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval + .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) .build() fusedLocationClient.requestLocationUpdates( locationRequest, @@ -246,22 +285,15 @@ class LocationService : Service() { } private fun stopLocationUpdatesInternal() { - Log.d("LocationService", "Removing location updates") fusedLocationClient.removeLocationUpdates(locationCallback) } fun setPowerMode(powerMode: PowerMode) { serviceScope.launch { if (_currentPowerMode.value != powerMode) { - // Emit the new power mode first _currentPowerMode.emit(powerMode) - Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.") - // Stop current updates if running stopLocationUpdatesInternal() - // Start new updates with the new power mode's interval startLocationUpdatesInternal(powerMode) - } else { - Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.") } } } @@ -278,25 +310,15 @@ class LocationService : Service() { private fun createNotification(): Notification { val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_IMMUTABLE - ) - + val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle("Sailing Companion") - .setContentText("Tracking your location in the background...") + .setContentText("Tracking your location...") .setSmallIcon(R.drawable.ic_anchor) .setContentIntent(pendingIntent) .build() } - /** - * Starts the anchor watch with the current location as the anchor point. - * @param radiusMeters The watch circle radius in meters. - */ @SuppressLint("MissingPermission") suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) { val lastLocation = fusedLocationClient.lastLocation.await() @@ -307,29 +329,27 @@ class LocationService : Service() { setTimeMillis = System.currentTimeMillis(), isActive = true ) } - Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m") - } ?: run { - Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.") - // Handle error, e.g., show a toast to the user } } - /** - * Stops the anchor watch. - */ fun stopAnchorWatch() { _anchorWatchState.update { AnchorWatchState(isActive = false) } - Log.i("AnchorWatch", "Anchor watch stopped.") anchorAlarmManager.stopAlarm() isAlarmTriggered = false } - /** - * Updates the watch circle radius. - */ fun updateWatchCircleRadius(radiusMeters: Double) { _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) } - Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.") + } + + fun snapshot(): EnvironmentalSnapshot { + val trueWind = _latestTrueWind.value + return EnvironmentalSnapshot( + windSpeedKt = trueWind?.speedKt, + windDirectionDeg = trueWind?.directionDeg, + currentSpeedKt = null, // TODO: Pull from latest forecast + currentDirectionDeg = null + ) } companion object { @@ -338,56 +358,42 @@ class LocationService : Service() { const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH" const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH" const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS" - const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY" const val EXTRA_WATCH_RADIUS = "extra_watch_radius" - const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility" - - // NMEA Gateway configuration (example values - these should ideally be configurable by the user) - private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address - private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port - - // Publicly accessible flows - val locationFlow: SharedFlow<GpsData> - get() = _locationFlow - val anchorWatchState: StateFlow<AnchorWatchState> - get() = _anchorWatchState - val tidalCurrentState: StateFlow<TidalCurrentState> - get() = _tidalCurrentState - val barometerStatus: StateFlow<BarometerStatus> - get() = _barometerStatus - - // NMEA Data Flows - val nmeaGpsPositionFlow: SharedFlow<GpsPosition> - get() = _nmeaGpsPositionFlow - val nmeaWindDataFlow: SharedFlow<WindData> - get() = _nmeaWindDataFlow - val nmeaDepthDataFlow: SharedFlow<DepthData> - get() = _nmeaDepthDataFlow - val nmeaHeadingDataFlow: SharedFlow<HeadingData> - get() = _nmeaHeadingDataFlow - - private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1) + + private const val NMEA_GATEWAY_IP = "192.168.1.1" + private const val NMEA_GATEWAY_PORT = 10110 + + private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1) + val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow + + private val _bestPosition = MutableStateFlow<GpsPosition?>(null) + val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow() + + private val _activeGpsSource = MutableStateFlow(GpsSource.NONE) + val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow() + private val _anchorWatchState = MutableStateFlow(AnchorWatchState()) - private val _tidalCurrentState = MutableStateFlow(TidalCurrentState()) + val anchorWatchState: StateFlow<AnchorWatchState> get() = _anchorWatchState + private val _barometerStatus = MutableStateFlow(BarometerStatus()) + val barometerStatus: StateFlow<BarometerStatus> get() = _barometerStatus - // Private NMEA Data Flows - private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val _nmeaWindDataFlow = MutableSharedFlow<WindData>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null) + val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow() + + private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(replay = 1) + val nmeaGpsPositionFlow: SharedFlow<GpsPosition> get() = _nmeaGpsPositionFlow + + private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(replay = 1) + val nmeaWindDataFlow: SharedFlow<WindData> get() = _nmeaWindDataFlow + + private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(replay = 1) + val nmeaDepthDataFlow: SharedFlow<DepthData> get() = _nmeaDepthDataFlow + + private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(replay = 1) + val nmeaHeadingDataFlow: SharedFlow<HeadingData> get() = _nmeaHeadingDataFlow private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) - val currentPowerMode: StateFlow<PowerMode> - get() = _currentPowerMode + val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode } } - diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index 3f09309..fd2cf61 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -39,6 +39,7 @@ import org.terst.nav.ui.doc.DocFragment import org.terst.nav.ui.safety.SafetyFragment import org.terst.nav.ui.voicelog.VoiceLogFragment import java.util.* +import org.terst.nav.safety.AnchorWatchState class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { @@ -46,7 +47,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var mobHandler: MobHandler? = null private var instrumentHandler: InstrumentHandler? = null private var mapHandler: MapHandler? = null - private var anchorWatchHandler: AnchorWatchHandler? = null private val loadedStyleFlow = MutableStateFlow<Style?>(null) private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> @@ -186,7 +186,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } override fun onConfigureAnchor() { - anchorWatchHandler?.toggleVisibility() + // Now handled via fragment navigation from SafetyFragment } private fun setupHandlers() { @@ -305,13 +305,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.locationFlow.collect { gpsData -> mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) - mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.courseOverGround) - val sogKnots = gpsData.speedOverGround * 1.94384 - val cogDeg = gpsData.courseOverGround - viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, cogDeg.toDouble()) + mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat()) + + viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog) instrumentHandler?.updateDisplay( - sog = "%.1f".format(Locale.getDefault(), sogKnots), - cog = "%.0f°".format(Locale.getDefault(), cogDeg) + sog = "%.1f".format(Locale.getDefault(), gpsData.sog), + cog = "%.0f°".format(Locale.getDefault(), gpsData.cog) ) if (!conditionsLoaded) { conditionsLoaded = true diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt index d427a5d..fc1d79d 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.data.model +package org.terst.nav.data.model data class SensorData( val latitude: Double? = null, diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt index e17e5ca..6a976f6 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt @@ -5,38 +5,20 @@ import org.terst.nav.data.model.GribRegion import java.time.Instant interface GribFileManager { - /** Save metadata for a newly-downloaded GRIB file. */ fun saveMetadata(file: GribFile) - /** Return all stored GRIB files for [region], newest first. */ fun listFiles(region: GribRegion): List<GribFile> - /** Return the most-recently-downloaded GRIB file for [region], or null if none. */ fun latestFile(region: GribRegion): GribFile? - /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */ fun delete(file: GribFile): Boolean - /** Delete all GRIB files older than [before]. Returns count of deleted files. */ fun purgeOlderThan(before: Instant): Int - /** Total size in bytes of all stored GRIB files. */ fun totalSizeBytes(): Long } class InMemoryGribFileManager : GribFileManager { private val files = mutableListOf<GribFile>() - override fun saveMetadata(file: GribFile) { files.add(file) } - - override fun listFiles(region: GribRegion): List<GribFile> = - files.filter { it.region.name == region.name } - .sortedByDescending { it.downloadedAt } - + override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt } override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull() - override fun delete(file: GribFile): Boolean = files.remove(file) - - override fun purgeOlderThan(before: Instant): Int { - val toRemove = files.filter { it.downloadedAt.isBefore(before) } - files.removeAll(toRemove) - return toRemove.size - } - + override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size } override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes } } diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt index 70f36d9..f39957b 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt @@ -1,7 +1,7 @@ -package com.example.androidapp.data.weather +package org.terst.nav.data.weather import org.terst.nav.data.model.GribFile -import com.example.androidapp.data.storage.GribFileManager +import org.terst.nav.data.storage.GribFileManager import org.terst.nav.data.model.GribRegion import java.time.Instant diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt index 6e565b7..875d971 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt @@ -1,10 +1,10 @@ -package com.example.androidapp.data.weather +package org.terst.nav.data.weather import org.terst.nav.data.model.GribFile import org.terst.nav.data.model.GribParameter import org.terst.nav.data.model.GribRegion import org.terst.nav.data.model.SatelliteDownloadRequest -import com.example.androidapp.data.storage.GribFileManager +import org.terst.nav.data.storage.GribFileManager import java.time.Instant import kotlin.math.ceil import kotlin.math.floor diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt index 5faf30c..99cef2d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt @@ -1,9 +1,20 @@ package org.terst.nav.gps +/** + * Represents a single GPS fix. + * + * @param latitude Degrees, positive = North, negative = South. + * @param longitude Degrees, positive = East, negative = West. + * @param sog Speed Over Ground in knots. + * @param cog Course Over Ground in degrees true (0-360). + * @param timestampMs Unix epoch milliseconds UTC. + * @param accuracyMeters Estimated horizontal accuracy (1-sigma) in meters; null if unknown. + */ data class GpsPosition( val latitude: Double, val longitude: Double, - val sog: Double, // knots - val cog: Double, // degrees true - val timestampMs: Long + val sog: Double, + val cog: Double, + val timestampMs: Long, + val accuracyMeters: Double? = null ) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt index d4cf50d..67cfcce 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.logbook +package org.terst.nav.logbook import org.terst.nav.data.model.LogbookEntry import java.util.Calendar diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt index 78ea834..6417db9 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.logbook +package org.terst.nav.logbook import android.graphics.Canvas import android.graphics.Color diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt index 453c758..6a470b8 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt @@ -273,8 +273,9 @@ class NmeaParser { val hours = timeStr.substring(0, 2).toInt() val minutes = timeStr.substring(2, 4).toInt() val seconds = timeStr.substring(4, 6).toInt() - val millis = if (timeStr.length > 7) { - (timeStr.substring(7).toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0 + val millis = if (timeStr.contains('.')) { + val fracStr = timeStr.substringAfter('.') + ("0.$fracStr".toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0 } else 0 cal.set(Calendar.HOUR_OF_DAY, hours) cal.set(Calendar.MINUTE, minutes) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt index 60a5918..13fb132 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.routing +package org.terst.nav.routing /** * The result of an isochrone weather routing computation. diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt index 901fdbc..8ac73cf 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.routing +package org.terst.nav.routing import org.terst.nav.data.model.BoatPolars import org.terst.nav.data.model.WindForecast diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt index 02988d1..a6562d9 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.routing +package org.terst.nav.routing /** * A single point in the isochrone routing tree. diff --git a/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt new file mode 100644 index 0000000..9121ce6 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt @@ -0,0 +1,40 @@ +package org.terst.nav.safety + +import android.location.Location +import kotlin.math.* + +/** + * Holds state for the anchor watch and provides the suggested watch-circle radius. + */ +data class AnchorWatchState( + val anchorLocation: Location? = null, + val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS, + val setTimeMillis: Long = 0L, + val isActive: Boolean = false +) { + companion object { + const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 + + /** + * Calculates the recommended watch circle radius based on depth, freeboard, and rode out. + */ + fun calculateRecommendedWatchCircleRadius( + depthMeters: Double, + freeboardMeters: Double, + rodeOutMeters: Double + ): Double { + if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) return 0.0 + val totalVerticalDistance = depthMeters + freeboardMeters + if (totalVerticalDistance > rodeOutMeters) return 0.0 + val angle = asin(totalVerticalDistance / rodeOutMeters) + return rodeOutMeters * cos(angle) + } + } + + fun isDragging(currentLocation: Location): Boolean { + anchorLocation ?: return false + if (!isActive) return false + val distance = anchorLocation.distanceTo(currentLocation) + return distance > watchCircleRadiusMeters + } +} diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt index 2bdbf6c..b1e5652 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.tide +package org.terst.nav.tide import com.example.androidapp.data.model.TidePrediction import com.example.androidapp.data.model.TideStation diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt deleted file mode 100644 index d55de90..0000000 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.terst.nav.ui - -import android.content.Context -import android.content.Intent -import android.view.View -import android.widget.Button -import android.widget.TextView -import android.widget.Toast -import androidx.constraintlayout.widget.ConstraintLayout -import org.terst.nav.AnchorWatchState -import org.terst.nav.LocationService -import java.util.Locale - -/** - * Handles the Anchor Watch UI interactions and state updates. - */ -class AnchorWatchHandler( - private val context: Context, - private val container: ConstraintLayout, - private val statusText: TextView, - private val radiusText: TextView, - private val buttonDecrease: Button, - private val buttonIncrease: Button, - private val buttonSet: Button, - private val buttonStop: Button -) { - private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS - - init { - updateRadiusDisplay() - - buttonDecrease.setOnClickListener { - updateRadius((currentRadius - 5).coerceAtLeast(10.0)) - } - - buttonIncrease.setOnClickListener { - updateRadius((currentRadius + 5).coerceAtMost(200.0)) - } - - buttonSet.setOnClickListener { - startWatch() - } - - buttonStop.setOnClickListener { - stopWatch() - } - } - - private fun updateRadius(newRadius: Double) { - currentRadius = newRadius - updateRadiusDisplay() - val intent = Intent(context, LocationService::class.java).apply { - action = LocationService.ACTION_UPDATE_WATCH_RADIUS - putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius) - } - context.startService(intent) - } - - private fun updateRadiusDisplay() { - radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius) - } - - private fun startWatch() { - val intent = Intent(context, LocationService::class.java).apply { - action = LocationService.ACTION_START_ANCHOR_WATCH - putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius) - } - context.startService(intent) - Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show() - } - - private fun stopWatch() { - val intent = Intent(context, LocationService::class.java).apply { - action = LocationService.ACTION_STOP_ANCHOR_WATCH - } - context.startService(intent) - Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show() - } - - /** - * Updates the UI based on the current anchor watch state. - */ - fun updateUI(state: AnchorWatchState) { - statusText.text = if (state.isActive) { - "STATUS: ACTIVE" // Simple status for UI - } else { - "STATUS: INACTIVE" - } - currentRadius = state.watchCircleRadiusMeters - updateRadiusDisplay() - } - - /** - * Toggles the visibility of the anchor configuration container. - */ - fun toggleVisibility() { - container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE - } -} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index 4f08de7..bfefb6f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -19,7 +19,7 @@ import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point import org.maplibre.geojson.Polygon -import org.terst.nav.AnchorWatchState +import org.terst.nav.safety.AnchorWatchState import org.terst.nav.TidalCurrentState import org.terst.nav.track.TrackPoint import kotlin.math.cos diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt index 289a857..d435f00 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.ui.anchorwatch +package org.terst.nav.ui.anchorwatch import android.os.Bundle import android.text.Editable @@ -9,7 +9,7 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import org.terst.nav.R import org.terst.nav.databinding.FragmentAnchorWatchBinding -import com.example.androidapp.safety.AnchorWatchState +import org.terst.nav.safety.AnchorWatchState class AnchorWatchHandler : Fragment() { @@ -43,7 +43,7 @@ class AnchorWatchHandler : Fragment() { val rode = binding.etRodeOut.text.toString().toDoubleOrNull() if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) { - val radius = anchorWatchState.calculateRecommendedWatchCircleRadius(depth, rode) + val radius = AnchorWatchState.calculateRecommendedWatchCircleRadius(depth, 2.0, rode) binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_fmt, radius) } else { diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt index 01656a3..fd504cb 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt @@ -1,3 +1,3 @@ -package com.example.androidapp.wind +package org.terst.nav.wind data class ApparentWind(val speedKt: Double, val angleDeg: Double) diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt index db32163..dc3117c 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.wind +package org.terst.nav.wind import kotlin.math.atan2 import kotlin.math.cos diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt index 78e9558..8c3ac56 100644 --- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt @@ -1,3 +1,3 @@ -package com.example.androidapp.wind +package org.terst.nav.wind data class TrueWindData(val speedKt: Double, val directionDeg: Double) |
