From 18c2f1c038f62fda1c1cea19c12dfdd4ce411602 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 15 Mar 2026 01:24:07 +0000 Subject: 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 --- .../main/kotlin/org/terst/nav/gps/GpsPosition.kt | 9 ++ .../main/kotlin/org/terst/nav/gps/GpsProvider.kt | 14 ++++ .../main/kotlin/org/terst/nav/nmea/NmeaParser.kt | 97 ++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 test-runner/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/gps/GpsProvider.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt (limited to 'test-runner/src/main/kotlin') 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 + } + } +} -- cgit v1.2.3