summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
commit97715ab4007ff3101f58edf4385cef1fc3d1615b (patch)
tree464bdb1df8cfed31402f5316fe84df974c0e59e2 /android-app/app/src/main
parent9f01ddfba17dda7fb386e83f007c671fec6d5b8e (diff)
refactor: unify core models and finish org.terst.nav migration
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt216
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt94
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt57
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt290
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt)4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt)4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt40
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt)6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt)2
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)