using GeoVLog.Core.Models; using System; using System.Globalization; using System.Text; namespace GeoVLog.Core.Parsers; /// /// Parser for NMEA $GPRMC and $GPGGA sentences. Only valid messages with a /// correct checksum are parsed. /// internal static class GpsParser { public static bool TryParse(ReadOnlySpan msg, DateTime arrivalUtc, out GpsReading parsed) { parsed = default!; var line = Encoding.ASCII.GetString(msg).Trim(); int star = line.IndexOf('*'); if (star <= 0 || star + 3 > line.Length) return false; byte cs = 0; for (int i = 1; i < star; i++) cs ^= (byte)line[i]; if (!byte.TryParse(line.AsSpan(star + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var given) || given != cs) return false; string[] parts = line[..star].Split(','); if (parts.Length == 0) return false; string id = parts[0]; if (id == "$GPRMC") return TryParseRmc(parts, line, out parsed); if (id == "$GPGGA") return TryParseGga(parts, line, arrivalUtc.Date, out parsed); return false; } private static bool TryParseRmc(string[] parts, string line, out GpsReading parsed) { parsed = default!; if (parts.Length < 12 || parts[2] != "A") return false; // invalid status if (!TryParseDateTime(parts[1], parts[9], out DateTime fixTime)) return false; if (!TryParseLatLon(parts[3], parts[4], out double lat) || !TryParseLatLon(parts[5], parts[6], out double lon)) return false; double? speed = ParseDoubleOrNull(parts[7]); double? track = ParseDoubleOrNull(parts[8]); double? magVar = null; if (parts.Length > 10 && !string.IsNullOrEmpty(parts[10])) { magVar = ParseDoubleOrNull(parts[10]); if (magVar.HasValue && parts.Length > 11 && parts[11] == "W") magVar = -magVar; } char mode = parts.Length > 12 && !string.IsNullOrEmpty(parts[12]) ? parts[12][0] : '\0'; parsed = new GpsReading { TimestampUtc = fixTime, RawLine = line, SentenceId = "$GPRMC", LatitudeDeg = lat, LongitudeDeg = lon, SpeedKnots = speed, HeadingDeg = track, Status = 'A', Mode = mode == '\0' ? null : mode, MagneticVariationDeg = magVar }; return true; } private static bool TryParseGga(string[] parts, string line, DateTime date, out GpsReading parsed) { parsed = default!; if (parts.Length < 15) return false; if (!byte.TryParse(parts[6], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte fixQuality) || fixQuality == 0) return false; // invalid fix if (!TryParseTime(parts[1], date, out DateTime fixTime)) return false; if (!TryParseLatLon(parts[2], parts[3], out double lat) || !TryParseLatLon(parts[4], parts[5], out double lon)) return false; if (!byte.TryParse(parts[7], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte sats)) return false; float? hdop = ParseFloatOrNull(parts[8]); if (!double.TryParse(parts[9], NumberStyles.Float, CultureInfo.InvariantCulture, out double alt)) return false; char altUnit = parts[10].Length > 0 ? parts[10][0] : 'M'; double? geoid = ParseDoubleOrNull(parts[11]); char geoidUnit = parts.Length > 12 && parts[12].Length > 0 ? parts[12][0] : 'M'; double? age = parts.Length > 13 ? ParseDoubleOrNull(parts[13]) : null; string? station = parts.Length > 14 && parts[14].Length > 0 ? parts[14] : null; parsed = new GpsReading { TimestampUtc = fixTime, RawLine = line, SentenceId = "$GPGGA", LatitudeDeg = lat, LongitudeDeg = lon, AltitudeM = alt, AltitudeUnits = altUnit, GeoidSeparation = geoid, GeoidUnits = geoidUnit, AgeDgpsSeconds = age, DgpsStationId = station, FixQuality = fixQuality, NumSatellites = sats, Hdop = hdop }; return true; } private static bool TryParseTime(string hhmmss, DateTime date, out DateTime result) { result = default; if (hhmmss.Length < 6) return false; if (!int.TryParse(hhmmss.Substring(0, 2), out int hh)) return false; if (!int.TryParse(hhmmss.Substring(2, 2), out int mm)) return false; if (!double.TryParse(hhmmss.Substring(4), NumberStyles.Float, CultureInfo.InvariantCulture, out double ss)) return false; int sec = (int)ss; int ms = (int)Math.Round((ss - sec) * 1000.0); result = new DateTime(date.Year, date.Month, date.Day, hh, mm, sec, DateTimeKind.Utc).AddMilliseconds(ms); return true; } private static bool TryParseDateTime(string hhmmss, string ddmmyy, out DateTime result) { result = default; if (!TryParseTime(hhmmss, DateTime.UtcNow.Date, out DateTime temp)) return false; if (ddmmyy.Length != 6 || !int.TryParse(ddmmyy.Substring(0, 2), out int dd) || !int.TryParse(ddmmyy.Substring(2, 2), out int MM) || !int.TryParse(ddmmyy.Substring(4, 2), out int yy)) return false; int year = 2000 + yy; result = new DateTime(year, MM, dd, temp.Hour, temp.Minute, temp.Second, temp.Kind).AddMilliseconds(temp.Millisecond); return true; } private static bool TryParseLatLon(string value, string hemi, out double degrees) { degrees = 0; if (string.IsNullOrEmpty(value) || value.Length < 3) return false; int degLen = hemi == "N" || hemi == "S" ? 2 : 3; if (!double.TryParse(value.Substring(0, degLen), NumberStyles.Integer, CultureInfo.InvariantCulture, out double degPart)) return false; if (!double.TryParse(value.Substring(degLen), NumberStyles.Float, CultureInfo.InvariantCulture, out double minPart)) return false; double raw = degPart + (minPart / 60.0); const double scale = 1_000_000.0; degrees = Math.Truncate(raw * scale) / scale; if (hemi == "S" || hemi == "W") degrees = -degrees; return true; } private static double? ParseDoubleOrNull(string s) => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : null; private static float? ParseFloatOrNull(string s) => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float v) ? v : null; }