Merge pull request #198 from ladyada-eagleclaw/gps-gnss-receiver-base

Connect the GNSS receiver base to shared NMEA framing
This commit is contained in:
Limor "Ladyada" Fried authored and GitHub committed 2026-09-16 12:36:33 -04:00
commit 0c9b45f58b
4 files changed
+240 -6

No files matched your search

+7
View File
@@ -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.
@@ -0,0 +1,151 @@
#include "Adafruit_GNSS.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <type_traits>
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<Adafruit_NMEA, Adafruit_GNSS>::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<Adafruit_GNSS>::value &&
!std::is_copy_assignable<Adafruit_GNSS>::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;
}
+70
View File
@@ -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.
+12 -6
View File
@@ -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,