From e2b86fdf73d4069f038e9340c28c07fe89903548 Mon Sep 17 00:00:00 2001 From: ladyada Date: Tue, 15 Sep 2026 14:01:30 -0400 Subject: [PATCH 1/2] Propose the reusable NMEA core API Co-authored-by: Limor Fried --- extras/design/Adafruit_NMEA.h | 148 ++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 extras/design/Adafruit_NMEA.h diff --git a/extras/design/Adafruit_NMEA.h b/extras/design/Adafruit_NMEA.h new file mode 100644 index 0000000..9e45a1d --- /dev/null +++ b/extras/design/Adafruit_NMEA.h @@ -0,0 +1,148 @@ +/**************************************************************************/ +/*! + @file Adafruit_NMEA.h + @brief Proposed hardware-independent NMEA API for review. + + Declarations only: this proposal is not implemented or installed from src/. + Private storage and class layout will be defined with the implementation. + Existing Adafruit_GPS code does not use this header. + + Intended inheritance: + Adafruit_NMEA -> Adafruit_GNSS -> Adafruit_GPS (existing MTK API) + -> Adafruit_LC29H (Quectel API) + + Written for Adafruit Industries. BSD license; see license.txt. +*/ +/**************************************************************************/ + +#ifndef ADAFRUIT_NMEA_H +#define ADAFRUIT_NMEA_H + +#include +#include + +/** Result of framing or validating a sentence. Recognition belongs to GNSS + * or receiver-specific decoders: an unknown address can still be VALID. */ +typedef enum : uint8_t { + NMEA_FRAME_INCOMPLETE, ///< More bytes are needed; no new sentence is ready. + NMEA_FRAME_VALID, ///< Complete format and checksum are valid. + NMEA_FRAME_BAD_FORMAT, ///< Invalid argument, address, framing, or trailer. + NMEA_FRAME_BAD_CHECKSUM, ///< Two hex checksum digits do not match the + ///< payload. + NMEA_FRAME_OVERFLOW ///< Receive buffer has no room for the whole sentence. +} nmea_frame_status_t; + +/** Borrowed text with an explicit length; not necessarily NUL-terminated. + * NULL data means absent. Non-NULL data with length zero means present/empty. + * The owner must keep the underlying storage alive and unchanged while used. + */ +typedef struct { + const char *data; ///< First character, or NULL for an absent field. + size_t length; ///< Number of characters, excluding any NUL terminator. +} nmea_span_t; + +/** Sentence views. Only text is available when a complete sentence is invalid; + * address and fields require VALID status. No text is copied or whitelisted. + */ +typedef struct { + nmea_frame_status_t status; ///< Result of validation. + nmea_span_t text; ///< Entire supplied sentence, including its trailer. + nmea_span_t address; ///< Full address, e.g. GNGGA, PAIR001, or PQTMVERNO. + nmea_span_t fields; ///< Text after the first comma and before '*'. +} nmea_sentence_t; + +/// Result of converting one complete field to a decimal number. +typedef enum : uint8_t { + NMEA_NUMBER_VALID, ///< The complete field is a representable decimal. + NMEA_NUMBER_MISSING, ///< Field is absent. + NMEA_NUMBER_EMPTY, ///< Field is present but empty. + NMEA_NUMBER_BAD_FORMAT, ///< Invalid character or decimal syntax. + NMEA_NUMBER_OUT_OF_RANGE ///< Coefficient or decimal-place count overflowed. +} nmea_number_status_t; + +/** Exact decimal value: coefficient / 10^decimalPlaces, with no float step. + * Coordinate interpretation and conversion to degrees belong to Adafruit_GNSS. + * On any conversion failure, coefficient and decimalPlaces are both zero. */ +typedef struct { + nmea_number_status_t status; ///< Result of conversion. + int64_t coefficient; ///< Signed integer containing the decimal digits. + uint8_t decimalPlaces; ///< Number of digits following the decimal point. +} nmea_decimal_t; + +/** Proposed framing and field utilities shared by receiver classes. + * + * No transport, GPS fix data, command waits, heap allocation, or field arrays. + * Framing state and timestamps belong to each instance. Supplied-buffer + * validation, field iteration, conversion, and command building are stateless. + * One caller feeds each instance; concurrent access requires synchronization. + */ +class Adafruit_NMEA { +public: + // Borrow two non-overlapping buffers, each capacity bytes including NUL. + // They must outlive this object. This permits reuse of GPS's existing pair. + // Invalid storage makes feed() return BAD_FORMAT without writing to it. + Adafruit_NMEA(char *firstBuffer, char *secondBuffer, size_t capacity); + + // Copies must not share writable receive buffers with independent state. + Adafruit_NMEA(const Adafruit_NMEA &) = delete; + Adafruit_NMEA &operator=(const Adafruit_NMEA &) = delete; + + // Drop receive state and invalidate all views returned by lastSentence(). + void reset(); + + // Supply one byte and its receive time in milliseconds (e.g. millis()). + // Ignore bytes before '$' or '!'; a new start character restarts assembly. + // LF completes the sentence; its format and checksum determine the result. + // Overflow discards that sentence and resumes at the next start character. + // Each complete, non-overflowed line replaces lastSentence(), even if + // invalid. Retaining invalid raw text permits the existing lastNMEA()/logger + // behavior. + nmea_frame_status_t feed(uint8_t byte, uint32_t receivedAtMs); + + // Return the latest complete line, or INCOMPLETE/absent spans before one. + // Views expire on the next complete line, reset(), or destruction. + // Process each line promptly: there is no queue of older sentences. + nmea_sentence_t lastSentence() const; + + // Time of the '$' or '!' of the latest complete sentence; zero before one + // exists. Zero is also a valid timestamp; use lastSentence().status to tell + // them apart. + uint32_t sentenceStartedAt() const; + + // Time of the LF of that same sentence; unsigned times wrap like millis(). + uint32_t sentenceReceivedAt() const; + + // Validate exactly length bytes, excluding NUL; never modify or overread + // data. Accept '$' or '!', a nonempty ASCII alphanumeric address, optional + // fields, + // '*', two hex checksum digits, then optional CR followed by optional LF. + // Reject embedded NUL/control characters and extra trailer characters. + // Missing/invalid hex digits are BAD_FORMAT; a mismatch is BAD_CHECKSUM. + // Zero fields means absent fields, e.g. "$PQTMVERNO*58". + // One empty field means non-NULL fields.data with zero length. + // NULL data yields BAD_FORMAT with all spans absent. + // Returned spans borrow data; receive state and timestamps are unchanged. + static nmea_sentence_t validate(const char *data, size_t length); + + // Start with a copy of a VALID sentence's fields span. Return the next field + // and advance remaining in place. Preserve empty and trailing empty fields. + // After the last field, remaining and subsequent results are absent. + // This supports one forward scan without a stored array of field offsets. + static nmea_span_t nextField(nmea_span_t &remaining); + + // Convert a whole field: optional '+'/'-', then digits and at most one '.'. + // At least one digit is required; whitespace, exponents, and NaN are invalid. + // Retain the supplied fractional digit count; never round or silently clamp. + static nmea_decimal_t parseDecimal(nmea_span_t field); + + // Build "$*HH\r\n" plus NUL into caller-provided storage. + // body contains an address and optional fields, without '$', '!', '*', NUL, + // or control characters. Source/output must not overlap. + // capacity includes NUL; return bytes excluding NUL, or zero on error. + // On error, clear output[0] if output is non-NULL and capacity is nonzero. + // Check arguments and capacity before writing; no heap allocation or I/O. + static size_t buildCommand(char *output, size_t capacity, const char *body, + size_t bodyLength); +}; + +#endif // ADAFRUIT_NMEA_H From 4ac934719e466bdbbb33add60a428b93a15b7055 Mon Sep 17 00:00:00 2001 From: ladyada Date: Tue, 15 Sep 2026 14:10:17 -0400 Subject: [PATCH 2/2] Add bounded NMEA sentence validation Co-authored-by: Limor Fried --- extras/tests/nmea_validate/nmea_validate.cpp | 105 ++++++++++++++++++ src/Adafruit_NMEA.cpp | 111 +++++++++++++++++++ src/Adafruit_NMEA.h | 52 +++++++++ 3 files changed, 268 insertions(+) create mode 100644 extras/tests/nmea_validate/nmea_validate.cpp create mode 100644 src/Adafruit_NMEA.cpp create mode 100644 src/Adafruit_NMEA.h diff --git a/extras/tests/nmea_validate/nmea_validate.cpp b/extras/tests/nmea_validate/nmea_validate.cpp new file mode 100644 index 0000000..b8b8066 --- /dev/null +++ b/extras/tests/nmea_validate/nmea_validate.cpp @@ -0,0 +1,105 @@ +/* Host regression for the hardware-independent core. From the repository root: + g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined -Isrc \ + src/Adafruit_NMEA.cpp extras/tests/nmea_validate/nmea_validate.cpp \ + -o /tmp/nmea_validate + /tmp/nmea_validate +*/ + +#include "Adafruit_NMEA.h" +#include +#include +#include +#include + +static void expect(const char *data, size_t length, nmea_frame_status_t status); +static void expectText(nmea_span_t span, const char *text); + +int main() { + const char *valid[] = { + "$A*41", "$A*41\r", "$A*41\n", "$A*41\r\n", + "!A*41", "$PQTMVERNO*58", "$PAIR001,004,0*3f", "$A, *4D"}; + for (const char *text : valid) + expect(text, strlen(text), NMEA_FRAME_VALID); + puts("PASS: checksums, line endings, unknown and proprietary addresses"); + + nmea_sentence_t sentence = + Adafruit_NMEA::validate("$PQTMVERNO*58", sizeof("$PQTMVERNO*58") - 1); + expectText(sentence.address, "PQTMVERNO"); + assert(sentence.fields.data == NULL && sentence.fields.length == 0); + sentence = + Adafruit_NMEA::validate("$PQTMVERNO,*74", sizeof("$PQTMVERNO,*74") - 1); + assert(sentence.status == NMEA_FRAME_VALID); + assert(sentence.fields.data != NULL && sentence.fields.length == 0); + sentence = Adafruit_NMEA::validate("$A,,*41", 7); + assert(sentence.status == NMEA_FRAME_VALID); + expectText(sentence.fields, ","); + puts("PASS: absent, empty, and trailing empty fields stay distinct"); + + const char *malformed[] = { + "", "$", "$A", "$A*", "$A*4", "$A*4Z", + "$A*Z1", "$A*+1", "$A*411", "$A*41junk", "$A*41\n\r", "$A*41\r\r", + "$A*41\n\n", "$*00", "$,*2C", "$A B*23", "$A,*", "$A**41", + "$A,$*49", "$A,!*4C", "$A,\t*64", "A*41", "$A*41$B*42"}; + for (const char *text : malformed) + expect(text, strlen(text), NMEA_FRAME_BAD_FORMAT); + expect("$PQTMVERNO*59", sizeof("$PQTMVERNO*59") - 1, NMEA_FRAME_BAD_CHECKSUM); + expect(NULL, 0, NMEA_FRAME_BAD_FORMAT); + expect(NULL, 100, NMEA_FRAME_BAD_FORMAT); + const char embeddedNul[] = {'$', 'A', ',', 0, '*', '6', 'D'}; + expect(embeddedNul, sizeof(embeddedNul), NMEA_FRAME_BAD_FORMAT); + const char nonAscii[] = {'$', 'A', ',', (char)0x80, '*', 'E', 'D'}; + expect(nonAscii, sizeof(nonAscii), NMEA_FRAME_BAD_FORMAT); + puts("PASS: malformed framing, checksum digits, control bytes, and NULL " + "input"); + + const char fix[] = + "$GPGGA,120009,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*40\r\n"; + const size_t length = sizeof(fix) - 1; + // Each allocation ends at the declared length, so AddressSanitizer detects + // any lookahead past it. The complete checksum needs no optional CR/LF. + for (size_t i = 0; i <= length; i++) { + char *bounded = (char *)malloc(i ? i : 1); + assert(bounded != NULL); + memcpy(bounded, fix, i); + nmea_frame_status_t status = NMEA_FRAME_BAD_FORMAT; + if (i >= length - 2) + status = NMEA_FRAME_VALID; + expect(bounded, i, status); + assert(memcmp(bounded, fix, i) == 0); + free(bounded); + } + puts("PASS: every truncated prefix and exact-length nonterminated input"); + + sentence = Adafruit_NMEA::validate(fix, length); + expectText(sentence.address, "GPGGA"); + expectText(sentence.fields, + "120009,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"); + nmea_sentence_t other = + Adafruit_NMEA::validate("$PQTMVERNO*58", sizeof("$PQTMVERNO*58") - 1); + expectText(other.address, "PQTMVERNO"); + expectText(sentence.address, "GPGGA"); + assert(sentence.text.data == fix && sentence.text.length == length); + puts("PASS: views borrow input and independent results do not overwrite it"); + return 0; +} + +static void expect(const char *data, size_t length, + nmea_frame_status_t status) { + nmea_sentence_t result = Adafruit_NMEA::validate(data, length); + if (result.status != status) { + fprintf(stderr, "FAIL: length %zu, expected status %d, got %d\n", length, + status, result.status); + abort(); + } + assert(result.text.data == data); + assert(result.text.length == (data ? length : 0)); + if (status != NMEA_FRAME_VALID) { + assert(result.address.data == NULL && result.address.length == 0); + assert(result.fields.data == NULL && result.fields.length == 0); + } +} + +static void expectText(nmea_span_t span, const char *text) { + assert(span.data != NULL && span.length == strlen(text)); + assert(memcmp(span.data, text, span.length) == 0); +} diff --git a/src/Adafruit_NMEA.cpp b/src/Adafruit_NMEA.cpp new file mode 100644 index 0000000..a2d42b4 --- /dev/null +++ b/src/Adafruit_NMEA.cpp @@ -0,0 +1,111 @@ +/**************************************************************************/ +/*! + @file Adafruit_NMEA.cpp + @brief Hardware-independent NMEA sentence validation. + + Written for Adafruit Industries. BSD license; see license.txt. +*/ +/**************************************************************************/ + +#include "Adafruit_NMEA.h" + +/**************************************************************************/ +/*! + @brief Validate a complete, length-bounded NMEA sentence without copying it. + @param data Readable input buffer; no NUL terminator is required. + @param length Number of bytes to inspect, excluding any NUL terminator. + @return Status and borrowed spans. Address and fields are absent on failure. + Raw text remains available unless data is NULL, which gives absent spans. + + Accepts '$' or '!', an ASCII alphanumeric address, optional comma-separated + fields, '*', two hex checksum digits, optional CR, and optional LF, in that + order. A valid but unknown address is accepted. Embedded start markers, + control characters, non-ASCII bytes, and extra trailer bytes are rejected. + Missing or invalid hex digits are BAD_FORMAT; a mismatch is BAD_CHECKSUM. + + A zero-field message such as "$PQTMVERNO*58" has absent fields. A comma + immediately followed by '*' denotes one empty field, with non-NULL data + and zero length. Returned views require the input to remain unchanged. + This function allocates no memory and has no shared or per-instance state. +*/ +/**************************************************************************/ +nmea_sentence_t Adafruit_NMEA::validate(const char *data, size_t length) { + nmea_sentence_t result = { + NMEA_FRAME_BAD_FORMAT, {NULL, 0}, {NULL, 0}, {NULL, 0}}; + if (!data) + return result; + result.text.data = data; + result.text.length = length; + + // The shortest sentence has a start, one address character, '*', and two hex + // digits. Check the length before reading even the start character. + if (length < 5 || (data[0] != '$' && data[0] != '!')) + return result; + + size_t addressEnd = 0; + size_t checksumOffset = 0; + uint8_t checksum = 0; + for (size_t i = 1; i < length; i++) { + uint8_t c = (uint8_t)data[i]; + if (c == '*') { + checksumOffset = i; + break; + } + if (c < ' ' || c > '~' || c == '$' || c == '!') + return result; + + if (!addressEnd) { + if (c == ',') { + addressEnd = i; + } else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'))) { + return result; + } + } + checksum ^= c; + } + + if (!checksumOffset) + return result; + if (!addressEnd) + addressEnd = checksumOffset; + if (addressEnd == 1 || length - checksumOffset < 3) + return result; + + // Decode both checksum digits only after proving that both are in bounds. + uint8_t expected = 0; + for (size_t i = checksumOffset + 1; i < checksumOffset + 3; i++) { + char c = data[i]; + uint8_t digit; + if (c >= '0' && c <= '9') + digit = c - '0'; + else if (c >= 'A' && c <= 'F') + digit = c - 'A' + 10; + else if (c >= 'a' && c <= 'f') + digit = c - 'a' + 10; + else + return result; + expected = expected * 16 + digit; + } + + size_t end = checksumOffset + 3; + if (end < length && data[end] == '\r') + end++; + if (end < length && data[end] == '\n') + end++; + if (end != length) + return result; + if (checksum != expected) { + result.status = NMEA_FRAME_BAD_CHECKSUM; + return result; + } + + result.status = NMEA_FRAME_VALID; + result.address.data = data + 1; + result.address.length = addressEnd - 1; + if (addressEnd < checksumOffset) { + result.fields.data = data + addressEnd + 1; + result.fields.length = checksumOffset - addressEnd - 1; + } + return result; +} diff --git a/src/Adafruit_NMEA.h b/src/Adafruit_NMEA.h new file mode 100644 index 0000000..a5bef50 --- /dev/null +++ b/src/Adafruit_NMEA.h @@ -0,0 +1,52 @@ +/**************************************************************************/ +/*! + @file Adafruit_NMEA.h + @brief Hardware-independent NMEA sentence validation. + + Written for Adafruit Industries. BSD license; see license.txt. +*/ +/**************************************************************************/ + +#ifndef ADAFRUIT_NMEA_H +#define ADAFRUIT_NMEA_H + +#include +#include + +/** Result of framing or validating a sentence. Recognition belongs to GNSS + * or receiver-specific decoders: an unknown address can still be VALID. */ +typedef enum : uint8_t { + NMEA_FRAME_INCOMPLETE, ///< More bytes are needed; no new sentence is ready. + NMEA_FRAME_VALID, ///< Complete format and checksum are valid. + NMEA_FRAME_BAD_FORMAT, ///< Invalid argument, address, framing, or trailer. + NMEA_FRAME_BAD_CHECKSUM, ///< Two hex checksum digits do not match the + ///< payload. + NMEA_FRAME_OVERFLOW ///< Receive buffer has no room for the whole sentence. +} nmea_frame_status_t; + +/** Borrowed text with an explicit length; not necessarily NUL-terminated. + * NULL data means absent. Non-NULL data with length zero means present/empty. + * The owner must keep the underlying storage alive and unchanged while used. + */ +typedef struct { + const char *data; ///< First character, or NULL for an absent field. + size_t length; ///< Number of characters, excluding any NUL terminator. +} nmea_span_t; + +/** Sentence views. Only text is available when a sentence is invalid; + * address and fields require VALID status. No text is copied or whitelisted. + */ +typedef struct { + nmea_frame_status_t status; ///< Result of validation. + nmea_span_t text; ///< Entire supplied sentence, including its trailer. + nmea_span_t address; ///< Full address, e.g. GNGGA, PAIR001, or PQTMVERNO. + nmea_span_t fields; ///< Text after the first comma and before '*'. +} nmea_sentence_t; + +/// Hardware-independent NMEA utilities shared by receiver classes. +class Adafruit_NMEA { +public: + static nmea_sentence_t validate(const char *data, size_t length); +}; + +#endif // ADAFRUIT_NMEA_H