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 } } }