From c943c22954132b21f3067b526b3c13f3300113dd Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Sun, 15 Mar 2026 00:58:54 +0000 Subject: Add GpsPosition data class and NMEA RMC parser with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GpsPosition: latitude, longitude, sog (knots), cog (degrees true), timestampMs - NmeaParser.parseRmc: handles GP/GN talker IDs, void status, malformed input - SOG/COG default to 0.0 when fields absent; S/W coords are negative - 13 unit tests: GpsPositionTest (2), NmeaParserTest (11) — all GREEN Co-Authored-By: Claude Sonnet 4.6 --- .../com/example/androidapp/gps/GpsPosition.kt | 9 ++ .../com/example/androidapp/nmea/NmeaParser.kt | 94 ++++++++++++++++++ .../com/example/androidapp/gps/GpsPositionTest.kt | 33 +++++++ .../com/example/androidapp/nmea/NmeaParserTest.kt | 105 +++++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt create mode 100644 android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt create mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt create mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt (limited to 'android-app') 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 new file mode 100644 index 0000000..6df685b --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt @@ -0,0 +1,9 @@ +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 +) 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 new file mode 100644 index 0000000..b1b186a --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt @@ -0,0 +1,94 @@ +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/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt new file mode 100644 index 0000000..8b2753c --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt @@ -0,0 +1,33 @@ +package com.example.androidapp.gps + +import org.junit.Assert.* +import org.junit.Test + +class GpsPositionTest { + + @Test + fun `GpsPosition holds correct values`() { + val pos = GpsPosition( + latitude = 41.5, + longitude = -71.0, + sog = 5.2, + cog = 180.0, + timestampMs = 1_000L + ) + assertEquals(41.5, pos.latitude, 0.0) + assertEquals(-71.0, pos.longitude, 0.0) + assertEquals(5.2, pos.sog, 0.0) + assertEquals(180.0, pos.cog, 0.0) + assertEquals(1_000L, pos.timestampMs) + } + + @Test + fun `GpsPosition equality works as expected for data class`() { + val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L) + val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L) + val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L) + + assertEquals(pos1, pos2) + assertNotEquals(pos1, pos3) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt new file mode 100644 index 0000000..b8a878a --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt @@ -0,0 +1,105 @@ +package com.example.androidapp.nmea + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class NmeaParserTest { + + private lateinit var parser: NmeaParser + + @Before + fun setUp() { + parser = NmeaParser() + } + + // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A + // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E + // SOG 22.4 kn, COG 84.4° + + @Test + fun `valid RMC sentence parses latitude and longitude`() { + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(48.1173, pos!!.latitude, 0.0001) + assertEquals(11.51667, pos.longitude, 0.0001) + } + + @Test + fun `valid RMC sentence parses SOG and COG`() { + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(22.4, pos!!.sog, 0.001) + assertEquals(84.4, pos.cog, 0.001) + } + + @Test + fun `void status V returns null`() { + val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W" + assertNull(parser.parseRmc(sentence)) + } + + @Test + fun `malformed sentence with too few fields returns null`() { + assertNull(parser.parseRmc("\$GPRMC,123519,A")) + } + + @Test + fun `empty string returns null`() { + assertNull(parser.parseRmc("")) + } + + @Test + fun `non-RMC sentence returns null`() { + val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,," + assertNull(parser.parseRmc(sentence)) + } + + @Test + fun `south latitude is negative`() { + // lat: -(42 + 50.5589/60) = -42.84265 + val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,," + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertTrue("South latitude must be negative", pos!!.latitude < 0) + assertEquals(-42.84265, pos.latitude, 0.0001) + } + + @Test + fun `west longitude is negative`() { + // lon: -(11 + 31.000/60) = -11.51667 + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertTrue("West longitude must be negative", pos!!.longitude < 0) + assertEquals(-11.51667, pos.longitude, 0.0001) + } + + @Test + fun `SOG and COG parse with decimal precision`() { + val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,," + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(12.345, pos!!.sog, 0.0001) + assertEquals(270.5, pos.cog, 0.0001) + } + + @Test + fun `empty SOG and COG fields default to zero`() { + val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(0.0, pos!!.sog, 0.001) + assertEquals(0.0, pos.cog, 0.001) + } + + @Test + fun `GNRMC talker ID is also accepted`() { + val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W" + val pos = parser.parseRmc(sentence) + assertNotNull(pos) + assertEquals(48.1173, pos!!.latitude, 0.0001) + } +} -- cgit v1.2.3