Decode exact GNSS positions into independent sentence results

Co-authored-by: Limor Fried <ladyada@users.noreply.github.com>
This commit is contained in:
ladyadaandLimor Fried committed 2026-09-16 10:55:57 -04:00
1 parent fe868faa69
commit 79e508fda5
4 files changed
+265 -9

No files matched your search

+5
View File
@@ -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.
@@ -0,0 +1,141 @@
#include "Adafruit_GNSS.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
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;
}
+101 -9
View File
@@ -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;
+18
View File
@@ -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.