summaryrefslogtreecommitdiff
path: root/test-runner/src/main/kotlin/org/terst
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-15 01:24:07 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-15 01:24:07 +0000
commit18c2f1c038f62fda1c1cea19c12dfdd4ce411602 (patch)
tree1cc888ce0afa758fa003eddba98a2fad9af8fd1e /test-runner/src/main/kotlin/org/terst
parentc7b42ab248cc1b3e8652469571121eb1a039d831 (diff)
feat: implement NMEA stream management, sensor data models, and power modes
- Added NmeaStreamManager for TCP connection and sentence parsing. - Extended NmeaParser to support MWV (wind), DBT (depth), and HDG/HDM (heading) sentences. - Added sensor data models: WindData, DepthData, HeadingData. - Introduced PowerMode enum to manage GPS update intervals. - Integrated NmeaStreamManager and PowerMode into LocationService. - Added test-runner, a standalone JVM-only Gradle project for verifying GPS/NMEA logic. Co-Authored-By: Gemini CLI <noreply@google.com>
Diffstat (limited to 'test-runner/src/main/kotlin/org/terst')
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt9
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt14
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt97
3 files changed, 120 insertions, 0 deletions
diff --git a/test-runner/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/test-runner/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
new file mode 100644
index 0000000..5faf30c
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
@@ -0,0 +1,9 @@
+package org.terst.nav.gps
+
+data class GpsPosition(
+ val latitude: Double,
+ val longitude: Double,
+ val sog: Double, // knots
+ val cog: Double, // degrees true
+ val timestampMs: Long
+)
diff --git a/test-runner/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt b/test-runner/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt
new file mode 100644
index 0000000..3c3d634
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt
@@ -0,0 +1,14 @@
+package org.terst.nav.gps
+
+interface GpsProvider {
+ fun start()
+ fun stop()
+ val position: GpsPosition?
+ fun addListener(listener: GpsListener)
+ fun removeListener(listener: GpsListener)
+}
+
+interface GpsListener {
+ fun onPositionUpdate(position: GpsPosition)
+ fun onFixLost()
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/test-runner/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
new file mode 100644
index 0000000..74f2c41
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -0,0 +1,97 @@
+package org.terst.nav.nmea
+
+import org.terst.nav.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
+
+ // Strip optional checksum (*XX suffix)
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+
+ val fields = body.split(',')
+ if (fields.size < 10) return null
+
+ // Sentence ID must end with "RMC"
+ if (!fields[0].endsWith("RMC")) return null
+
+ // Status must be Active; Void means no valid fix
+ if (fields[2] != "A") return null
+
+ val latStr = fields[3]
+ val latDir = fields[4]
+ val lonStr = fields[5]
+ val lonDir = fields[6]
+
+ if (latStr.isEmpty() || latDir.isEmpty() || lonStr.isEmpty() || lonDir.isEmpty()) 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[7].toDoubleOrNull() ?: 0.0
+ val cog = fields[8].toDoubleOrNull() ?: 0.0
+
+ val timestampMs = parseTimestamp(timeStr = fields[1], dateStr = fields[9])
+
+ return GpsPosition(latitude, longitude, sog, cog, timestampMs)
+ }
+
+ /**
+ * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
+ * Works for both latitude (DDMM.MM) and longitude (DDDMM.MM) formats.
+ */
+ 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 a Unix epoch milliseconds value.
+ * 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 // Calendar is 0-based
+ 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) {
+ (timeStr.substring(7).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
+ }
+ }
+}