diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-15 00:58:54 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:53:52 +0000 |
| commit | c943c22954132b21f3067b526b3c13f3300113dd (patch) | |
| tree | 20357247605caa0043a2bcd60fdc4237f01f97c3 /android-app/app/src/main/kotlin/com | |
| parent | a9d87b600848178b03b85a06ccdfd53b11e38c38 (diff) | |
Add GpsPosition data class and NMEA RMC parser with tests
- 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 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main/kotlin/com')
| -rw-r--r-- | android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt | 9 | ||||
| -rw-r--r-- | android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt | 94 |
2 files changed, 103 insertions, 0 deletions
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 + } + } +} |
