From 1f5936353de654f430b0caadc5e6a5b75e7e26d9 Mon Sep 17 00:00:00 2001 From: ladyada Date: Wed, 16 Sep 2026 11:49:16 -0400 Subject: [PATCH] Connect the GNSS receiver base to shared NMEA framing Co-authored-by: Limor Fried --- extras/tests/README.md | 7 + extras/tests/gnss_receiver/gnss_receiver.cpp | 151 +++++++++++++++++++ src/Adafruit_GNSS.cpp | 70 +++++++++ src/Adafruit_GNSS.h | 18 ++- 4 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 extras/tests/gnss_receiver/gnss_receiver.cpp diff --git a/extras/tests/README.md b/extras/tests/README.md index 5cf500d..8675fcf 100644 --- a/extras/tests/README.md +++ b/extras/tests/README.md @@ -46,6 +46,13 @@ 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. +The GNSS receiver regression feeds interleaved bytes into two receiver subclasses, +checks independent timestamps and exact results, and exercises partial lines, +overflow recovery, invalid checksums, reset, and mixed proprietary replies. +`Adafruit_GNSS(firstBuffer, secondBuffer, capacity)` inherits `feed()` and +`lastSentence()` from the NMEA core; call `lastPosition()` after a completed line. +It returns that line's position, without caching or merging a previous fix. + 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_receiver/gnss_receiver.cpp b/extras/tests/gnss_receiver/gnss_receiver.cpp new file mode 100644 index 0000000..2c40411 --- /dev/null +++ b/extras/tests/gnss_receiver/gnss_receiver.cpp @@ -0,0 +1,151 @@ +#include "Adafruit_GNSS.h" +#include +#include +#include +#include + +class Receiver : public Adafruit_GNSS { +public: + using Adafruit_GNSS::Adafruit_GNSS; +}; + +static size_t build(char *line, size_t capacity, const char *body) { + size_t length = + Adafruit_NMEA::buildCommand(line, capacity, body, strlen(body)); + assert(length); + return length; +} + +static nmea_frame_status_t feed(Adafruit_GNSS &receiver, const char *line, + size_t length, uint32_t start) { + nmea_frame_status_t status = NMEA_FRAME_INCOMPLETE; + for (size_t i = 0; i < length; i++) + status = receiver.feed(line[i], start + (uint32_t)i); + return status; +} + +static void noPosition(const gnss_position_t &position, + gnss_sentence_status_t status) { + assert(position.validation.status == status); + assert(position.latitude.status == NMEA_NUMBER_MISSING && + position.longitude.status == NMEA_NUMBER_MISSING && + position.time.status == NMEA_NUMBER_MISSING && + position.date.status == NMEA_NUMBER_MISSING && + position.fixStatus == NMEA_NUMBER_MISSING && + position.fixQualityStatus == NMEA_NUMBER_MISSING); + assert(!position.latitude.fractionalMinutes && + !position.longitude.degreesE7 && !position.fix && + !position.fixQuality); +} + +int main() { + static_assert(std::is_base_of::value, + "GNSS shares the NMEA receiver"); + static_assert(sizeof(Adafruit_GNSS) == sizeof(Adafruit_NMEA), + "GNSS must not add persistent receive or fix storage"); + static_assert(!std::is_copy_constructible::value && + !std::is_copy_assignable::value, + "Receivers must not share writable buffers through copies"); + char first[128], second[128], third[128], fourth[128]; + Receiver one(first, second, sizeof(first)), two(third, fourth, sizeof(third)); + noPosition(one.lastPosition(), GNSS_SENTENCE_INVALID_FRAME); + Adafruit_GNSS disabled; + assert(disabled.feed('$', 1) == NMEA_FRAME_BAD_FORMAT); + noPosition(disabled.lastPosition(), GNSS_SENTENCE_INVALID_FRAME); + + char rmc[128], gga[128]; + size_t rmcLength = + build(rmc, sizeof(rmc), + "GNRMC,123519.123,A,8959.123456789,S,17959.123456789,W,1,2,290224"); + size_t ggaLength = + build(gga, sizeof(gga), + "GPGGA,010203,4807.038,N,01131.000,E,4,08,0.9,545.4,M,46.9"); + uint32_t start = UINT32_MAX - 10; + for (size_t i = 0; i < rmcLength || i < ggaLength; i++) { + if (i < rmcLength) + one.feed(rmc[i], start + (uint32_t)i); + if (i < ggaLength) + two.feed(gga[i], 500 + (uint32_t)i); + } + gnss_position_t saved = one.lastPosition(); + assert(saved.validation.status == GNSS_SENTENCE_VALID && saved.fix && + saved.latitude.fractionalMinutes == 123456789 && saved.date.day == 29); + gnss_position_t other = two.lastPosition(); + assert(other.validation.status == GNSS_SENTENCE_VALID && + other.fixQuality == 4 && other.latitude.degreesE7 == 481173000 && + other.time.hour == 1); + assert(one.sentenceStartedAt() == start && + one.sentenceReceivedAt() == start + (uint32_t)rmcLength - 1 && + two.sentenceStartedAt() == 500 && + two.sentenceReceivedAt() == 500 + ggaLength - 1); + assert(one.lastPosition().longitude.fractionalMinutes == 123456789); + assert(!memcmp(one.lastText().data, rmc, rmcLength)); + char coordinate[GNSS_COORDINATE_TEXT_SIZE]; + assert(Adafruit_GNSS::formatCoordinate(coordinate, sizeof(coordinate), + saved.longitude)); + assert(!strcmp(coordinate, "-179.98539094648")); + puts("PASS: subclass reception, interleaved receivers, exact output, and " + "wraparound times"); + + assert(one.feed('$', 1000) == NMEA_FRAME_INCOMPLETE); + assert(one.feed('G', 1001) == NMEA_FRAME_INCOMPLETE); + assert(one.lastPosition().latitude.fractionalMinutes == 123456789); + unsigned overflows = 0; + for (size_t i = 0; i < sizeof(first) + 5; i++) + overflows += one.feed('A', 1002 + i) == NMEA_FRAME_OVERFLOW; + assert(overflows == 1 && one.sentenceStartedAt() == start); + assert(one.lastPosition().longitude.fractionalMinutes == 123456789); + assert(feed(one, gga, ggaLength, 2000) == NMEA_FRAME_VALID); + assert(one.lastPosition().fixQuality == 4); + assert(saved.longitude.fractionalMinutes == 123456789 && + saved.date.day == 29); + puts("PASS: partial/oversized input preserves the last line and recovery " + "replaces it"); + + char line[128]; + const char *unsupported[] = {"PQTMVERNO", "PAIR001,062,0", "PGGLL,,,,,,A", + "GPGSV,1,1,00", "GPRMCX,1", "GLL,1", + "gPGLL,,,,,,A", "1PGLL,,,,,,A"}; + for (const char *body : unsupported) { + size_t length = build(line, sizeof(line), body); + assert(feed(one, line, length, 3000) == NMEA_FRAME_VALID); + noPosition(one.lastPosition(), GNSS_SENTENCE_UNSUPPORTED); + assert(one.lastSentence().status == NMEA_FRAME_VALID && + !memcmp(one.lastText().data, line, length)); + } + const char *talkers[] = {"GP", "GN", "GL", "GA", "GB", "GQ", "GI", "II"}; + for (const char *talker : talkers) { + char body[64]; + snprintf(body, sizeof(body), "%sGLL,4807.038,N,01131.000,E,123519,A", + talker); + size_t length = build(line, sizeof(line), body); + nmea_sentence_t sentence = Adafruit_NMEA::validate(line, length); + assert(Adafruit_GNSS::parsePosition(sentence).validation.status == + GNSS_SENTENCE_VALID); + line[0] = '!'; // The start marker is outside the checksum. + sentence = Adafruit_NMEA::validate(line, length); + assert(sentence.status == NMEA_FRAME_VALID); + noPosition(Adafruit_GNSS::parsePosition(sentence), + GNSS_SENTENCE_UNSUPPORTED); + } + puts("PASS: standard talkers route to positions; replies remain available to " + "subclasses"); + + size_t length = + build(line, sizeof(line), "GPGLL,4807.038,N,01131.000,E,123519,Q"); + assert(feed(one, line, length, 4000) == NMEA_FRAME_VALID); + noPosition(one.lastPosition(), GNSS_SENTENCE_INVALID_FIELD); + assert(one.lastPosition().validation.field == 6); + line[length - 4] = line[length - 4] == '0' ? '1' : '0'; + assert(feed(one, line, length, 5000) == NMEA_FRAME_BAD_CHECKSUM); + noPosition(one.lastPosition(), GNSS_SENTENCE_INVALID_FRAME); + assert(one.lastSentence().status == NMEA_FRAME_BAD_CHECKSUM); + one.reset(); + noPosition(one.lastPosition(), GNSS_SENTENCE_INVALID_FRAME); + assert(one.sentenceStartedAt() == 0 && one.sentenceReceivedAt() == 0); + assert(two.lastPosition().fixQuality == 4 && two.sentenceStartedAt() == 500); + assert(saved.latitude.fractionalMinutes == 123456789); + puts("PASS: malformed navigation, checksum failure, reset, and owned result " + "lifetime"); + return 0; +} diff --git a/src/Adafruit_GNSS.cpp b/src/Adafruit_GNSS.cpp index a28b4f2..195caef 100644 --- a/src/Adafruit_GNSS.cpp +++ b/src/Adafruit_GNSS.cpp @@ -12,6 +12,76 @@ static nmea_number_status_t parseSixDigits(nmea_span_t field, uint8_t *pairs); static bool validUnsignedByte(nmea_span_t field); +/**************************************************************************/ +/*! + @brief Create a GNSS receiver using the shared bounded NMEA framer. + @param firstBuffer First caller-owned writable receive buffer. + @param secondBuffer Second buffer, disjoint from the first. + @param capacity Bytes per buffer, including NUL space. + + Storage must outlive the receiver. The default or invalid storage disables + feed() as described by Adafruit_NMEA; static decoding remains available. + Supply incoming bytes and their timestamps with the inherited feed(). + No position cache, transport, command handling, or allocation is added. +*/ +/**************************************************************************/ +Adafruit_GNSS::Adafruit_GNSS(volatile char *firstBuffer, + volatile char *secondBuffer, size_t capacity) + : Adafruit_NMEA(firstBuffer, secondBuffer, capacity) {} + +/**************************************************************************/ +/*! + @brief Decode the latest complete line into an independent position result. + @return Exact GGA/RMC/GLL measurements, or validation diagnostics. + + Call after feed() reports a complete line, and synchronize with feed() if + using interrupts. Each call validates and decodes the current line; it does + not consume it. Before a line or after reset(), the result is INVALID_FRAME. + Invalid completed lines replace earlier positions; proprietary replies and + other sentences return UNSUPPORTED and remain accessible via lastSentence(). + Partial/overflowing input preserves the previous complete line, just as the + framer does. Use sentenceStartedAt() and sentenceReceivedAt() for this line. + Returned values own their data and survive later input or reset(). +*/ +/**************************************************************************/ +gnss_position_t Adafruit_GNSS::lastPosition() const { + return parsePosition(lastSentence()); +} + +/**************************************************************************/ +/*! + @brief Route a validated standard NMEA sentence to the position decoder. + @param sentence Unmodified view returned by validate() or lastSentence(). + @return Independent values; INVALID_FRAME for non-VALID input, UNSUPPORTED + for a valid frame outside the supported standard position sentences. + + Keep the borrowed storage readable and unchanged throughout the call. + Trust the supplied frame status; use validate() first for arbitrary text. + A standard position address has two uppercase talker letters followed by + GGA, RMC, or GLL. Any such talker is accepted except the proprietary 'P' + prefix. Encapsulated '!' messages and proprietary addresses are not routed. + No receiver-specific address whitelist or command handling is imposed on + the underlying framer. Invalid/unsupported input returns no measurements. +*/ +/**************************************************************************/ +gnss_position_t Adafruit_GNSS::parsePosition(const nmea_sentence_t &sentence) { + nmea_span_t type = {NULL, 0}, fields = {NULL, 0}; + if (sentence.status == NMEA_FRAME_VALID && sentence.text.data && + sentence.text.length && sentence.text.data[0] == '$' && + sentence.address.data && sentence.address.length == 5) { + const char *address = sentence.address.data; + if (address[0] >= 'A' && address[0] <= 'Z' && address[0] != 'P' && + address[1] >= 'A' && address[1] <= 'Z') { + type = {address + 2, 3}; + fields = sentence.fields; + } + } + gnss_position_t result = parsePosition(type, fields); + if (sentence.status != NMEA_FRAME_VALID) + result.validation.status = GNSS_SENTENCE_INVALID_FRAME; + return result; +} + /**************************************************************************/ /*! @brief Decode a latitude or longitude field and its hemisphere. diff --git a/src/Adafruit_GNSS.h b/src/Adafruit_GNSS.h index 91f7dd1..777eb2a 100644 --- a/src/Adafruit_GNSS.h +++ b/src/Adafruit_GNSS.h @@ -50,13 +50,14 @@ typedef enum : uint8_t { GNSS_SENTENCE_VALID, ///< Supported fields are valid or empty. GNSS_SENTENCE_UNSUPPORTED, ///< This validator does not handle the type. GNSS_SENTENCE_MISSING_FIELDS, ///< A required field position is absent. - GNSS_SENTENCE_INVALID_FIELD ///< A populated field has invalid content. + GNSS_SENTENCE_INVALID_FIELD, ///< A populated field has invalid content. + GNSS_SENTENCE_INVALID_FRAME ///< No complete frame with valid checksum. } gnss_sentence_status_t; /// Validation result; coordinate-pair errors identify the coordinate field. typedef struct { gnss_sentence_status_t status; ///< Result of navigation field validation. - uint8_t field; ///< One-based error field, or zero for valid/unsupported. + uint8_t field; ///< One-based error field, or zero without a field error. } gnss_validation_t; /** One sentence's position data, independent of earlier receiver state. @@ -75,17 +76,22 @@ typedef struct { 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. +/** Standard GNSS receiver and decoding shared by receiver implementations. + * Inherits bounded framing with caller-owned buffers and per-instance times. + * Position decoding is stateless: no fix is cached or merged across lines. + * Static decoding does not require receive storage. No heap or transport I/O. */ -class Adafruit_GNSS { +class Adafruit_GNSS : public Adafruit_NMEA { public: + Adafruit_GNSS(volatile char *firstBuffer = NULL, + volatile char *secondBuffer = NULL, size_t capacity = 0); + gnss_position_t lastPosition() const; static gnss_coordinate_t parseCoordinate(nmea_span_t coordinate, 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_position_t parsePosition(const nmea_sentence_t &sentence); 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,