From 092677dfee5ab31d5b0787c0be05657c5461be01 Mon Sep 17 00:00:00 2001 From: ladyada Date: Tue, 15 Sep 2026 15:49:13 -0400 Subject: [PATCH] Add bounded NMEA byte framing Assemble lines in caller-owned buffers with overflow recovery, stable views of the last complete line, and independent per-receiver timestamps. Add an automatically discovered host regression for boundaries and interleaved receivers. Co-authored-by: Limor Fried --- extras/tests/nmea_framing/nmea_framing.cpp | 243 +++++++++++++++++++++ src/Adafruit_NMEA.cpp | 141 ++++++++++++ src/Adafruit_NMEA.h | 24 +- 3 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 extras/tests/nmea_framing/nmea_framing.cpp diff --git a/extras/tests/nmea_framing/nmea_framing.cpp b/extras/tests/nmea_framing/nmea_framing.cpp new file mode 100644 index 0000000..525f35f --- /dev/null +++ b/extras/tests/nmea_framing/nmea_framing.cpp @@ -0,0 +1,243 @@ +// Automatically run by extras/tests/run_tests.py, without Arduino or hardware. +#include "Adafruit_NMEA.h" +#include +#include +#include +#include +#include + +static void expectText(nmea_span_t span, const char *text); +static nmea_frame_status_t feedLine(Adafruit_NMEA &receiver, const char *text, + uint32_t start); +static void checkCapacity(); +static void checkStorage(); +static void checkIndependentReceivers(); + +int main() { + static_assert(!std::is_copy_constructible::value, + "Receivers must not share writable buffers through copies"); + static_assert(!std::is_copy_assignable::value, + "Receivers must not copy receive state"); + char first[32], second[32]; + Adafruit_NMEA receiver(first, second, sizeof(first)); + nmea_sentence_t sentence = receiver.lastSentence(); + assert(sentence.status == NMEA_FRAME_INCOMPLETE); + assert(!sentence.text.data && !sentence.address.data && + !sentence.fields.data); + assert(!sentence.text.length && !sentence.address.length && + !sentence.fields.length); + assert(receiver.sentenceStartedAt() == 0 && + receiver.sentenceReceivedAt() == 0); + assert(first[0] == 0 && second[0] == 0); + for (char c : "noise\r\n") + assert(receiver.feed(c, 10) == NMEA_FRAME_INCOMPLETE); + assert(receiver.lastSentence().status == NMEA_FRAME_INCOMPLETE); + + assert(feedLine(receiver, "$A*41\r\n", 0) == NMEA_FRAME_VALID); + sentence = receiver.lastSentence(); + expectText(sentence.text, "$A*41\r\n"); + expectText(sentence.address, "A"); + assert(sentence.fields.data == NULL); + assert(sentence.text.data == first && first[sentence.text.length] == 0); + assert(receiver.sentenceStartedAt() == 0 && + receiver.sentenceReceivedAt() == 6); + puts("PASS: noise, initial state, CR/LF, borrowed storage, and zero " + "timestamp"); + + const char *partial = "$unfinished"; + for (size_t i = 0; i < strlen(partial); i++) + assert(receiver.feed(partial[i], 20 + i) == NMEA_FRAME_INCOMPLETE); + expectText(sentence.text, "$A*41\r\n"); + assert(receiver.sentenceStartedAt() == 0 && + receiver.sentenceReceivedAt() == 6); + assert(feedLine(receiver, "!A,1*5C\n", 100) == NMEA_FRAME_VALID); + sentence = receiver.lastSentence(); + expectText(sentence.text, "!A,1*5C\n"); + expectText(sentence.fields, "1"); + assert(sentence.text.data == second); + assert(receiver.sentenceStartedAt() == 100 && + receiver.sentenceReceivedAt() == 107); + assert(feedLine(receiver, "$PQTMVERNO*58\n", 200) == NMEA_FRAME_VALID); + expectText(receiver.lastSentence().address, "PQTMVERNO"); + assert(receiver.lastSentence().text.data == first); + puts("PASS: restart markers, LF-only lines, proprietary commands, and buffer " + "reuse"); + + const char *invalid[] = {"$A*40\n", "$A*4Z\n", "$A\n", "$A*41\rX\n"}; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + nmea_frame_status_t expected = NMEA_FRAME_BAD_FORMAT; + if (i == 0) + expected = NMEA_FRAME_BAD_CHECKSUM; + assert(feedLine(receiver, invalid[i], 300) == expected); + sentence = receiver.lastSentence(); + assert(sentence.status == expected); + expectText(sentence.text, invalid[i]); + assert(!sentence.address.data && !sentence.fields.data); + assert(receiver.sentenceStartedAt() == 300); + assert(receiver.sentenceReceivedAt() == 300 + strlen(invalid[i]) - 1); + } + const uint8_t nulLine[] = {'$', 'A', ',', 0, '*', '6', 'D', '\n'}; + for (size_t i = 0; i < sizeof(nulLine); i++) { + nmea_frame_status_t expected = NMEA_FRAME_INCOMPLETE; + if (i == sizeof(nulLine) - 1) + expected = NMEA_FRAME_BAD_FORMAT; + assert(receiver.feed(nulLine[i], 400 + i) == expected); + } + sentence = receiver.lastSentence(); + assert(sentence.text.length == sizeof(nulLine)); + assert(memcmp(sentence.text.data, nulLine, sizeof(nulLine)) == 0); + puts("PASS: invalid complete lines retain raw text and update timestamps"); + + receiver.feed('$', 500); + receiver.reset(); + assert(receiver.lastSentence().status == NMEA_FRAME_INCOMPLETE); + assert(receiver.sentenceStartedAt() == 0 && + receiver.sentenceReceivedAt() == 0); + assert(first[0] == 0 && second[0] == 0); + assert(receiver.feed('\n', 501) == NMEA_FRAME_INCOMPLETE); + assert(feedLine(receiver, "$A*41\n", 600) == NMEA_FRAME_VALID); + puts("PASS: reset discards completed and partial lines and permits reuse"); + + checkCapacity(); + checkStorage(); + checkIndependentReceivers(); + return 0; +} + +static void expectText(nmea_span_t span, const char *text) { + assert(span.data && span.length == strlen(text)); + assert(memcmp(span.data, text, span.length) == 0); + assert(span.data[span.length] == 0 || span.data[span.length] == '*' || + span.data[span.length] == ','); +} + +static nmea_frame_status_t feedLine(Adafruit_NMEA &receiver, const char *text, + uint32_t start) { + nmea_frame_status_t result = NMEA_FRAME_INCOMPLETE; + for (size_t i = 0; i < strlen(text); i++) { + result = receiver.feed(text[i], start + (uint32_t)i); + if (text[i] != '\n') + assert(result == NMEA_FRAME_INCOMPLETE); + } + return result; +} + +static void checkCapacity() { + const char line[] = "$A*41\r\n"; + for (size_t capacity = 0; capacity <= sizeof(line) + 1; capacity++) { + // Allocations end at capacity so ASan detects a misplaced NUL or LF. + char *first = (char *)malloc(capacity ? capacity : 1); + char *second = (char *)malloc(capacity ? capacity : 1); + assert(first && second); + memset(first, '?', capacity ? capacity : 1); + memset(second, '?', capacity ? capacity : 1); + { + Adafruit_NMEA receiver(first, second, capacity); + unsigned overflows = 0; + for (size_t i = 0; i < sizeof(line) - 1; i++) { + nmea_frame_status_t status = receiver.feed(line[i], i); + if (capacity < 2) { + assert(status == NMEA_FRAME_BAD_FORMAT); + assert(first[0] == '?' && second[0] == '?'); + } else if (capacity < sizeof(line) && i == capacity - 1) { + assert(status == NMEA_FRAME_OVERFLOW); + overflows++; + } else if (capacity >= sizeof(line) && i == sizeof(line) - 2) { + assert(status == NMEA_FRAME_VALID); + } else { + assert(status == NMEA_FRAME_INCOMPLETE); + } + } + if (capacity >= sizeof(line)) + expectText(receiver.lastSentence().text, line); + else + assert(receiver.lastSentence().status == NMEA_FRAME_INCOMPLETE); + assert(overflows == (unsigned)(capacity >= 2 && capacity < sizeof(line))); + } + free(first); + free(second); + } + + char first[8], second[8]; + Adafruit_NMEA receiver(first, second, sizeof(first)); + assert(feedLine(receiver, "$A*41\n", 10) == NMEA_FRAME_VALID); + nmea_span_t retained = receiver.lastSentence().text; + for (char c : "$AAAAAA") { + if (c) + assert(receiver.feed(c, 30) == NMEA_FRAME_INCOMPLETE); + } + assert(receiver.feed('A', 31) == NMEA_FRAME_OVERFLOW); + for (char c : "ignored*41\n") + assert(receiver.feed(c, 32) == NMEA_FRAME_INCOMPLETE); + expectText(retained, "$A*41\n"); + assert(receiver.sentenceStartedAt() == 10 && + receiver.sentenceReceivedAt() == 15); + assert(feedLine(receiver, "!A*41\n", 40) == NMEA_FRAME_VALID); + expectText(receiver.lastSentence().text, "!A*41\n"); + + char largeFirst[300], largeSecond[300], body[270], command[300]; + memset(body, '0', sizeof(body)); + body[0] = 'A'; + body[1] = ','; + assert(Adafruit_NMEA::buildCommand(command, sizeof(command), body, + sizeof(body))); + Adafruit_NMEA large(largeFirst, largeSecond, sizeof(largeFirst)); + assert(feedLine(large, command, 50) == NMEA_FRAME_VALID); + expectText(large.lastSentence().text, command); + puts("PASS: every capacity boundary, overflow recovery, and lines over 255 " + "bytes"); +} + +static void checkStorage() { + char storage[16]; + char original[16]; + memset(storage, '?', sizeof(storage)); + memcpy(original, storage, sizeof(storage)); + char *first[] = {NULL, storage, storage, storage, storage + 7}; + char *second[] = {storage, NULL, storage, storage + 7, storage}; + for (size_t i = 0; i < sizeof(first) / sizeof(first[0]); i++) { + Adafruit_NMEA receiver(first[i], second[i], 8); + assert(receiver.feed('$', 1) == NMEA_FRAME_BAD_FORMAT); + receiver.reset(); + assert(receiver.lastSentence().status == NMEA_FRAME_INCOMPLETE); + assert(memcmp(storage, original, sizeof(storage)) == 0); + } + for (unsigned reverse = 0; reverse < 2; reverse++) { + char *a = storage; + char *b = storage + 8; + if (reverse) { + a = storage + 8; + b = storage; + } + Adafruit_NMEA adjacent(a, b, 8); + assert(feedLine(adjacent, "$A*41\n", 10) == NMEA_FRAME_VALID); + assert(feedLine(adjacent, "!A*41\n", 20) == NMEA_FRAME_VALID); + } + puts("PASS: NULL and overlapping storage stay untouched; adjacent buffers " + "work"); +} + +static void checkIndependentReceivers() { + char a[16], b[16], c[16], d[16]; + Adafruit_NMEA first(a, b, sizeof(a)); + Adafruit_NMEA second(c, d, sizeof(c)); + const char line[] = "$A*41\n"; + for (size_t i = 0; i < sizeof(line) - 1; i++) { + nmea_frame_status_t expected = NMEA_FRAME_INCOMPLETE; + if (i == sizeof(line) - 2) + expected = NMEA_FRAME_VALID; + assert(first.feed(line[i], UINT32_MAX - 2 + (uint32_t)i) == expected); + assert(second.feed(line[i], 100 + i) == expected); + } + assert(first.sentenceStartedAt() == UINT32_MAX - 2); + assert(first.sentenceReceivedAt() == 2); + assert(second.sentenceStartedAt() == 100 && + second.sentenceReceivedAt() == 105); + nmea_span_t retained = second.lastSentence().text; + first.reset(); + expectText(retained, line); + expectText(second.lastSentence().text, line); + assert(second.sentenceReceivedAt() == 105); + puts("PASS: interleaved receivers have independent buffers, reset, and " + "wrapping timestamps"); +} diff --git a/src/Adafruit_NMEA.cpp b/src/Adafruit_NMEA.cpp index 35c351e..7d7f54d 100644 --- a/src/Adafruit_NMEA.cpp +++ b/src/Adafruit_NMEA.cpp @@ -10,6 +10,147 @@ #include "Adafruit_NMEA.h" #include +/**************************************************************************/ +/*! + @brief Create a receiver using two caller-owned buffers. + @param firstBuffer First writable receive buffer. + @param secondBuffer Second writable receive buffer, not overlapping first. + @param capacity Size of each buffer, including space for a NUL terminator. + + Both buffers must outlive the receiver and remain exclusively owned by it. + NULL pointers, overlapping buffers, or capacity below two bytes disable + reception: feed() returns BAD_FORMAT and no buffer is written. Valid + buffers are initialized to empty strings. No memory is allocated. +*/ +/**************************************************************************/ +Adafruit_NMEA::Adafruit_NMEA(char *firstBuffer, char *secondBuffer, + size_t capacity) + : _buffer(firstBuffer), _lastBuffer(secondBuffer), _capacity(capacity) { + if (!firstBuffer || !secondBuffer || capacity < 2) { + _capacity = 0; + } else { + // Subtract addresses instead of adding capacity to avoid end overflow. + uintptr_t firstAddress = (uintptr_t)firstBuffer; + uintptr_t secondAddress = (uintptr_t)secondBuffer; + if (firstAddress <= secondAddress) { + if (secondAddress - firstAddress < capacity) + _capacity = 0; + } else if (firstAddress - secondAddress < capacity) { + _capacity = 0; + } + } + reset(); +} + +/**************************************************************************/ +/*! + @brief Discard partial and completed lines and clear all timestamps. + + Invalidates every view returned by lastSentence(). Valid receive buffers + become empty strings. Invalid storage remains disabled and untouched. +*/ +/**************************************************************************/ +void Adafruit_NMEA::reset() { + _length = _lastLength = 0; + _startedAt = _lastStarted = _lastReceived = 0; + if (_capacity) { + _buffer[0] = '\0'; + _lastBuffer[0] = '\0'; + } +} + +/**************************************************************************/ +/*! + @brief Feed one byte into this receiver's bounded sentence buffer. + @param byte Received byte, including any CR/LF characters. + @param receivedAtMs Receive time in milliseconds, such as millis(). + @return INCOMPLETE until LF completes a line, then its validation status. + OVERFLOW discards a line that cannot fit including LF and NUL. BAD_FORMAT + also indicates invalid storage supplied to the constructor. + + Ignore bytes before '$' or '!'. Either start marker restarts assembly, + including after overflow. Overflow is reported once; subsequent bytes + are ignored until another start marker. Partial and overflowing lines + preserve the previous complete line and its timestamps. + + Every complete, non-overflowed line replaces lastSentence(), even if its + format or checksum is invalid. Its raw text includes LF and is followed + by a NUL terminator. The caller must consume each line promptly; there is + no queue. No transport access, allocation, or sentence copying occurs. +*/ +/**************************************************************************/ +nmea_frame_status_t Adafruit_NMEA::feed(uint8_t byte, uint32_t receivedAtMs) { + if (!_capacity) + return NMEA_FRAME_BAD_FORMAT; + + if (byte == '$' || byte == '!') { + _length = 0; + _startedAt = receivedAtMs; + } else if (!_length) { + return NMEA_FRAME_INCOMPLETE; + } + + // Reserve one byte for NUL, including when the incoming byte is LF. + if (_length == _capacity - 1) { + _length = 0; + _buffer[0] = '\0'; + return NMEA_FRAME_OVERFLOW; + } + _buffer[_length++] = (char)byte; + _buffer[_length] = '\0'; + if (byte != '\n') + return NMEA_FRAME_INCOMPLETE; + + // Publish by swapping buffers, leaving the new completed line undisturbed + // while the next line is assembled. + char *previous = _lastBuffer; + _lastBuffer = _buffer; + _buffer = previous; + _lastLength = _length; + _lastStarted = _startedAt; + _lastReceived = receivedAtMs; + _length = 0; + _buffer[0] = '\0'; + return validate(_lastBuffer, _lastLength).status; +} + +/**************************************************************************/ +/*! + @brief Get views of the latest complete line, including invalid raw text. + @return Validated sentence, or INCOMPLETE with absent spans before a line + has completed or after reset(). Overflow does not replace this result. + + Views borrow the receive buffer and expire on the next complete line, + reset(), or receiver destruction. Address and fields are only available + for VALID lines. Validation uses the stored length, not the NUL terminator. +*/ +/**************************************************************************/ +nmea_sentence_t Adafruit_NMEA::lastSentence() const { + if (_lastLength) + return validate(_lastBuffer, _lastLength); + nmea_sentence_t result = { + NMEA_FRAME_INCOMPLETE, {NULL, 0}, {NULL, 0}, {NULL, 0}}; + return result; +} + +/**************************************************************************/ +/*! + @brief Get the start-marker timestamp of the latest complete line. + @return Supplied receive time in milliseconds, or zero before a line exists. + Zero is also a valid timestamp; use lastSentence().status to distinguish it. +*/ +/**************************************************************************/ +uint32_t Adafruit_NMEA::sentenceStartedAt() const { return _lastStarted; } + +/**************************************************************************/ +/*! + @brief Get the LF timestamp of the latest complete line. + @return Supplied receive time in milliseconds, or zero before a line exists. + Timestamps retain the caller's uint32_t wraparound behavior. +*/ +/**************************************************************************/ +uint32_t Adafruit_NMEA::sentenceReceivedAt() const { return _lastReceived; } + /**************************************************************************/ /*! @brief Validate a complete, length-bounded NMEA sentence without copying it. diff --git a/src/Adafruit_NMEA.h b/src/Adafruit_NMEA.h index dab6d40..5af008f 100644 --- a/src/Adafruit_NMEA.h +++ b/src/Adafruit_NMEA.h @@ -63,14 +63,36 @@ typedef struct { uint8_t decimalPlaces; ///< Number of digits following the decimal point. } nmea_decimal_t; -/// Hardware-independent NMEA utilities shared by receiver classes. +/** Hardware-independent NMEA framing and utilities shared by receiver classes. + * Receive storage is borrowed, with no heap allocation. Each instance has its + * own receive state and timestamps; callers must synchronize concurrent use. + */ class Adafruit_NMEA { public: + Adafruit_NMEA(char *firstBuffer, char *secondBuffer, size_t capacity); + Adafruit_NMEA(const Adafruit_NMEA &) = delete; + Adafruit_NMEA &operator=(const Adafruit_NMEA &) = delete; + void reset(); + nmea_frame_status_t feed(uint8_t byte, uint32_t receivedAtMs); + nmea_sentence_t lastSentence() const; + uint32_t sentenceStartedAt() const; + uint32_t sentenceReceivedAt() const; + static nmea_sentence_t validate(const char *data, size_t length); static nmea_span_t nextField(nmea_span_t &remaining); static nmea_decimal_t parseDecimal(nmea_span_t field); static size_t buildCommand(char *output, size_t capacity, const char *body, size_t bodyLength); + +private: + char *_buffer; ///< Buffer currently receiving bytes. + char *_lastBuffer; ///< Buffer holding the latest complete line. + size_t _capacity; ///< Bytes per buffer, or zero for invalid storage. + size_t _length; ///< Current line length; zero while awaiting a start. + size_t _lastLength; ///< Latest complete line length, excluding NUL. + uint32_t _startedAt; ///< Start time of the line being assembled. + uint32_t _lastStarted; ///< Start time of the latest complete line. + uint32_t _lastReceived; ///< LF time of the latest complete line. }; #endif // ADAFRUIT_NMEA_H