summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
blob: b1b186a6d07653aa4810a90d4ca40ddec95ef915 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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
        }
    }
}