Merge pull request #188 from ladyada-eagleclaw/nmea-byte-framing

Add bounded NMEA byte framing
This commit is contained in:
Limor "Ladyada" Fried authored and GitHub committed 2026-09-15 18:23:30 -04:00
commit b452647a5a
3 files changed
+412 -1

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
// Automatically run by extras/tests/run_tests.py, without Arduino or hardware.
#include "Adafruit_NMEA.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <type_traits>
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<Adafruit_NMEA>::value,
"Receivers must not share writable buffers through copies");
static_assert(!std::is_copy_assignable<Adafruit_NMEA>::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");
}
+141
View File
@@ -10,6 +10,147 @@
#include "Adafruit_NMEA.h"
#include <string.h>
/**************************************************************************/
/*!
@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.
+28 -1
View File
@@ -63,14 +63,41 @@ 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);
/// @brief Copying is disabled to prevent sharing writable receive buffers.
/// @param other Receiver that cannot be copied.
Adafruit_NMEA(const Adafruit_NMEA &other) = delete;
/// @brief Assignment is disabled to prevent sharing writable receive buffers.
/// @param other Receiver that cannot be assigned.
/// @return No value; this deleted operation cannot be called.
Adafruit_NMEA &operator=(const Adafruit_NMEA &other) = 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