diff --git a/extras/tests/README.md b/extras/tests/README.md index c49a0d2..5cf500d 100644 --- a/extras/tests/README.md +++ b/extras/tests/README.md @@ -41,6 +41,11 @@ full-precision RTK outputs. Future receiver APIs and logging examples must preserve the core precision through storage, public results, and formatting without requiring a global float-type override. +The shared position decoder regression checks GGA/RMC/GLL results, independent +fix and GGA-quality status, empty/missing fields, exact coordinates through text +output, and rejection of malformed sentences without partial measurements. +Returned values remain independent of the input buffer and other receivers. + Physical reception tests live separately in `extras/hw_tests/gnss_receive`. Build and run them explicitly on the documented Nano or HILBERT fixture; the host test runner does not operate attached boards. diff --git a/extras/tests/gnss_position/gnss_position.cpp b/extras/tests/gnss_position/gnss_position.cpp new file mode 100644 index 0000000..195627a --- /dev/null +++ b/extras/tests/gnss_position/gnss_position.cpp @@ -0,0 +1,141 @@ +#include "Adafruit_GNSS.h" +#include +#include +#include + +static nmea_span_t text(const char *value) { + nmea_span_t span = {value, value ? strlen(value) : 0}; + return span; +} + +static gnss_position_t decode(const char *type, const char *fields) { + return Adafruit_GNSS::parsePosition(text(type), text(fields)); +} + +static void rejected(const char *type, const char *fields, + gnss_sentence_status_t status, uint8_t field) { + gnss_position_t value = decode(type, fields); + assert(value.validation.status == status && value.validation.field == field); + assert(value.latitude.status == NMEA_NUMBER_MISSING && + value.longitude.status == NMEA_NUMBER_MISSING && + value.time.status == NMEA_NUMBER_MISSING && + value.date.status == NMEA_NUMBER_MISSING && + value.fixStatus == NMEA_NUMBER_MISSING && + value.fixQualityStatus == NMEA_NUMBER_MISSING); + assert(!value.latitude.degreesE7 && !value.latitude.degrees && + !value.latitude.minutes && !value.latitude.fractionalMinutes && + !value.latitude.hemisphere && !value.longitude.degreesE7 && + !value.longitude.degrees && !value.longitude.minutes && + !value.longitude.fractionalMinutes && !value.longitude.hemisphere); + assert(!value.fix && !value.fixQuality && !value.time.hour && + !value.time.minute && !value.time.second && !value.time.millisecond && + !value.date.day && !value.date.month && !value.date.year); +} + +int main() { + gnss_position_t rmc = decode( + "RMC", + "123519.123,A,8959.123456789,S,17959.123456789,W,0.01,0.02,290224"); + assert(rmc.validation.status == GNSS_SENTENCE_VALID && !rmc.validation.field); + assert(rmc.fixStatus == NMEA_NUMBER_VALID && rmc.fix); + assert(rmc.fixQualityStatus == NMEA_NUMBER_MISSING && !rmc.fixQuality); + assert(rmc.latitude.status == NMEA_NUMBER_VALID && + rmc.longitude.status == NMEA_NUMBER_VALID); + assert(rmc.latitude.degrees == 89 && rmc.latitude.minutes == 59 && + rmc.latitude.fractionalMinutes == 123456789 && + rmc.longitude.degrees == 179 && rmc.longitude.minutes == 59 && + rmc.longitude.fractionalMinutes == 123456789); + assert(rmc.time.status == NMEA_NUMBER_VALID && rmc.time.hour == 12 && + rmc.time.minute == 35 && rmc.time.second == 19 && + rmc.time.millisecond == 123); + assert(rmc.date.status == NMEA_NUMBER_VALID && rmc.date.day == 29 && + rmc.date.month == 2 && rmc.date.year == 24); + char output[GNSS_COORDINATE_TEXT_SIZE]; + assert(Adafruit_GNSS::formatCoordinate(output, sizeof(output), rmc.latitude)); + assert(!strcmp(output, "-89.98539094648")); + assert( + Adafruit_GNSS::formatCoordinate(output, sizeof(output), rmc.longitude)); + assert(!strcmp(output, "-179.98539094648")); + puts("PASS: exact RMC coordinates, UTC/date, and GGA-independent fix"); + + gnss_position_t gga = + decode("GGA", "123520,4807.038,N,01131.000,E,4,08,0.9,545.4,M,-46.9"); + assert(gga.validation.status == GNSS_SENTENCE_VALID && gga.fix); + assert(gga.fixStatus == NMEA_NUMBER_VALID && + gga.fixQualityStatus == NMEA_NUMBER_VALID && gga.fixQuality == 4); + assert(gga.latitude.degreesE7 == 481173000 && + gga.longitude.degreesE7 == 115166666 && + gga.date.status == NMEA_NUMBER_MISSING); + gnss_position_t gll = decode("GLL", "4807.038,N,01131.000,E,123521,A"); + assert(gll.validation.status == GNSS_SENTENCE_VALID && gll.fix && + gll.fixStatus == NMEA_NUMBER_VALID && gll.time.second == 21 && + gll.date.status == NMEA_NUMBER_MISSING && + gll.fixQualityStatus == NMEA_NUMBER_MISSING); + gll = decode("GLL", "4807.038,N,01131.000,E,123521,V"); + assert(gll.validation.status == GNSS_SENTENCE_VALID && !gll.fix && + gll.fixStatus == NMEA_NUMBER_VALID && + gll.latitude.status == NMEA_NUMBER_VALID); + assert(rmc.latitude.fractionalMinutes == 123456789 && rmc.time.second == 19); + puts("PASS: GGA RTK quality, GLL validity, and independent sentence results"); + + gnss_position_t empty = decode("RMC", ",V,,,,,,,"); + assert(empty.validation.status == GNSS_SENTENCE_VALID && !empty.fix); + assert(empty.latitude.status == NMEA_NUMBER_EMPTY && + empty.longitude.status == NMEA_NUMBER_EMPTY && + empty.time.status == NMEA_NUMBER_EMPTY && + empty.date.status == NMEA_NUMBER_EMPTY && + empty.fixStatus == NMEA_NUMBER_VALID && + empty.fixQualityStatus == NMEA_NUMBER_MISSING); + empty = decode("GGA", ",,,,,0,00,99.99,,M,"); + assert(empty.validation.status == GNSS_SENTENCE_VALID && !empty.fix && + empty.fixStatus == NMEA_NUMBER_VALID && + empty.fixQualityStatus == NMEA_NUMBER_VALID && !empty.fixQuality && + empty.latitude.status == NMEA_NUMBER_EMPTY); + empty = decode("GLL", ",,,,,"); + assert(empty.validation.status == GNSS_SENTENCE_VALID && + empty.fixStatus == NMEA_NUMBER_EMPTY && !empty.fix); + empty = decode("GGA", ",,,,,,,,,,"); + assert(empty.validation.status == GNSS_SENTENCE_VALID && + empty.fixStatus == NMEA_NUMBER_EMPTY && + empty.fixQualityStatus == NMEA_NUMBER_EMPTY && !empty.fix); + puts("PASS: empty fields, missing sentence-specific fields, and no-fix data"); + + rejected("RMC", "123519,A,4807.038,N,01131.000,E,1,2,310426", + GNSS_SENTENCE_INVALID_FIELD, 9); + rejected("GGA", "123519,4807.038,N,01131.000,E,1,08,0.9,NaN,M,1", + GNSS_SENTENCE_INVALID_FIELD, 9); + rejected("GLL", "4807.038,N,01131.000,N,123519,A", + GNSS_SENTENCE_INVALID_FIELD, 3); + rejected("GGA", "123519,4807.038", GNSS_SENTENCE_MISSING_FIELDS, 3); + rejected("RMC", NULL, GNSS_SENTENCE_MISSING_FIELDS, 1); + rejected("GSA", "", GNSS_SENTENCE_UNSUPPORTED, 0); + rejected("VTG", NULL, GNSS_SENTENCE_UNSUPPORTED, 0); + rejected("GLLX", "", GNSS_SENTENCE_UNSUPPORTED, 0); + rejected(NULL, NULL, GNSS_SENTENCE_UNSUPPORTED, 0); + puts("PASS: early and late failures return no partial position data"); + + char sentence[120]; + const char *body = + "GNRMC,123519,A,8959.123456889,S,17959.123456889,W,1,2,290224"; + size_t length = Adafruit_NMEA::buildCommand(sentence, sizeof(sentence), body, + strlen(body)); + nmea_sentence_t frame = Adafruit_NMEA::validate(sentence, length); + assert(frame.status == NMEA_FRAME_VALID && frame.address.length == 5); + gnss_position_t copied = + Adafruit_GNSS::parsePosition({frame.address.data + 2, 3}, frame.fields); + assert(copied.validation.status == GNSS_SENTENCE_VALID); + memset(sentence, 'x', sizeof(sentence)); + assert(copied.latitude.degreesE7 == rmc.latitude.degreesE7 && + copied.latitude.fractionalMinutes == 123456889); + assert( + Adafruit_GNSS::formatCoordinate(output, sizeof(output), copied.latitude)); + assert(!strcmp(output, "-89.98539094815")); + const char bounded[] = "4807.038,N,01131.000,E,123519,Aignored"; + const char kind[] = {'G', 'L', 'L', 'X'}; + copied = + Adafruit_GNSS::parsePosition({kind, 3}, {bounded, sizeof(bounded) - 8}); + assert(copied.validation.status == GNSS_SENTENCE_VALID && copied.fix); + puts("PASS: framed input, owned results, bounded spans, and submillimeter " + "text"); + return 0; +} diff --git a/src/Adafruit_GNSS.cpp b/src/Adafruit_GNSS.cpp index 861bd9c..ff869b9 100644 --- a/src/Adafruit_GNSS.cpp +++ b/src/Adafruit_GNSS.cpp @@ -304,15 +304,7 @@ gnss_validation_t Adafruit_GNSS::validateNavigation(nmea_span_t type, gnss_validation_t result = {GNSS_SENTENCE_UNSUPPORTED, 0}; if (!type.data || type.length != 3) return result; - uint8_t sentence = UNSUPPORTED; - if (type.data[0] == 'G' && type.data[1] == 'G' && type.data[2] == 'A') - sentence = GGA; - else if (type.data[0] == 'R' && type.data[1] == 'M' && type.data[2] == 'C') - sentence = RMC; - else if (type.data[0] == 'G' && type.data[1] == 'L' && type.data[2] == 'L') - sentence = GLL; - else if (type.data[0] == 'G' && type.data[1] == 'S' && type.data[2] == 'A') - sentence = GSA; + uint8_t sentence = sentenceType(type); if (sentence == UNSUPPORTED) return result; uint8_t required = 6, latitudeField = 1, timeField = 5; @@ -387,6 +379,106 @@ gnss_validation_t Adafruit_GNSS::validateNavigation(nmea_span_t type, return result; } +/**************************************************************************/ +/*! + @brief Identify a supported three-character standard sentence type. + @param type Three-character identifier already bounded by the caller. + @return Private sentence kind, or UNSUPPORTED. +*/ +/**************************************************************************/ +uint8_t Adafruit_GNSS::sentenceType(nmea_span_t type) { + uint8_t sentence = UNSUPPORTED; + if (type.data[0] == 'G' && type.data[1] == 'G' && type.data[2] == 'A') + sentence = GGA; + else if (type.data[0] == 'R' && type.data[1] == 'M' && type.data[2] == 'C') + sentence = RMC; + else if (type.data[0] == 'G' && type.data[1] == 'L' && type.data[2] == 'L') + sentence = GLL; + else if (type.data[0] == 'G' && type.data[1] == 'S' && type.data[2] == 'A') + sentence = GSA; + return sentence; +} + +/**************************************************************************/ +/*! + @brief Decode a GGA, RMC, or GLL position without retaining receiver state. + @param type Three-character sentence type without its talker prefix. + @param fields Borrowed fields after the address comma and before '*'. + @return Independent values and validation diagnostics. Invalid or + unsupported input returns no partially decoded measurements. + + Call after validating the enclosing frame with Adafruit_NMEA::validate(). + Input storage must remain readable and unchanged throughout the call. + All navigation fields consumed by the GPS decoder are validated first, + including fields this position result does not expose (such as altitude). + Optional tails retain validateNavigation()'s existing behavior. + + Populated coordinates retain all nine fractional-minute digits and can be + passed directly to formatCoordinate(). Empty fields have EMPTY status; + fields absent from this sentence type have MISSING status. The fix boolean + is meaningful only with VALID fixStatus and does not imply populated + coordinates. GGA quality is independent of RMC/GLL fix validity. + + Time/date and exact position components belong only to this sentence. No + timestamps, previous-fix merging, allocation, or floating-point conversion + occur here. GSA and other sentence types are UNSUPPORTED by this decoder. +*/ +/**************************************************************************/ +gnss_position_t Adafruit_GNSS::parsePosition(nmea_span_t type, + nmea_span_t fields) { + gnss_position_t result = {}; + result.validation.status = GNSS_SENTENCE_UNSUPPORTED; + result.latitude.status = result.longitude.status = NMEA_NUMBER_MISSING; + result.time.status = result.date.status = NMEA_NUMBER_MISSING; + result.fixStatus = result.fixQualityStatus = NMEA_NUMBER_MISSING; + if (!type.data || type.length != 3) + return result; + uint8_t sentence = sentenceType(type); + if (sentence != GGA && sentence != RMC && sentence != GLL) + return result; + result.validation = validateNavigation(type, fields); + if (result.validation.status != GNSS_SENTENCE_VALID) + return result; + + uint8_t latitudeField = 1, timeField = 5, fixField = 6, lastField = 6; + if (sentence == GGA) { + latitudeField = 2; + timeField = 1; + } else if (sentence == RMC) { + latitudeField = 3; + timeField = 1; + fixField = 2; + lastField = 9; + } + for (uint8_t i = 1; i <= lastField; i++) { + nmea_span_t field = Adafruit_NMEA::nextField(fields); + if (i == latitudeField || i == latitudeField + 2) { + nmea_span_t hemisphere = Adafruit_NMEA::nextField(fields); + if (i == latitudeField) + result.latitude = parseCoordinate(field, hemisphere); + else + result.longitude = parseCoordinate(field, hemisphere); + i++; + } else if (i == timeField) { + result.time = parseTime(field); + } else if (i == fixField) { + result.fixStatus = field.length ? NMEA_NUMBER_VALID : NMEA_NUMBER_EMPTY; + if (sentence == GGA) { + result.fixQualityStatus = result.fixStatus; + // Validation already proved that this integer fits uint8_t. + for (size_t j = 0; j < field.length; j++) + result.fixQuality = result.fixQuality * 10 + field.data[j] - '0'; + result.fix = result.fixQuality > 0; + } else { + result.fix = field.length && field.data[0] == 'A'; + } + } else if (sentence == RMC && i == 9) { + result.date = parseDate(field); + } + } + return result; +} + // Keep integer-only byte fields exact; accepting atof-style prefixes can wrap. static bool validUnsignedByte(nmea_span_t field) { uint16_t value = 0; diff --git a/src/Adafruit_GNSS.h b/src/Adafruit_GNSS.h index 1f294e1..ef9fc11 100644 --- a/src/Adafruit_GNSS.h +++ b/src/Adafruit_GNSS.h @@ -59,6 +59,22 @@ typedef struct { uint8_t field; ///< One-based error field, or zero for valid/unsupported. } gnss_validation_t; +/** One sentence's position data, independent of earlier receiver state. + * Check validation first, then each field's status. A valid sentence does not + * imply a position fix or populated coordinates. Failed/unsupported decoding + * leaves all measurement values zero and all field statuses MISSING. */ +typedef struct { + gnss_validation_t validation; ///< Sentence status and first failing field. + gnss_coordinate_t latitude; ///< Exact latitude components. + gnss_coordinate_t longitude; ///< Exact longitude components. + gnss_time_t time; ///< UTC time, or EMPTY for a blank time field. + gnss_date_t date; ///< RMC date; MISSING in GGA/GLL, possibly EMPTY. + nmea_number_status_t fixStatus; ///< Fix field status. + bool fix; ///< Valid fix; check fixStatus first. + nmea_number_status_t fixQualityStatus; ///< Quality field status. + uint8_t fixQuality; ///< GGA quality, including RTK values; check its status. +} gnss_position_t; + /** Stateless standard GNSS decoding shared by receiver implementations. * Inputs are borrowed spans. Decoding neither allocates memory nor performs * transport I/O, and does not alter the supplied text or receiver state. @@ -69,12 +85,14 @@ public: nmea_span_t hemisphere); static size_t formatCoordinate(char *output, size_t capacity, const gnss_coordinate_t &coordinate); + static gnss_position_t parsePosition(nmea_span_t type, nmea_span_t fields); static gnss_time_t parseTime(nmea_span_t field); static gnss_date_t parseDate(nmea_span_t field); static gnss_validation_t validateNavigation(nmea_span_t type, nmea_span_t fields); private: + static uint8_t sentenceType(nmea_span_t type); /// Sentence kinds handled by the navigation validator. enum { UNSUPPORTED, ///< Sentence has no standard navigation validation.