Merge pull request #186 from ladyada-eagleclaw/main

Add exact decimals and NMEA command construction
This commit is contained in:
Limor "Ladyada" Fried authored and GitHub committed 2026-09-15 15:49:30 -04:00
commit faedf19cb8
4 files changed
+376 -2

No files matched your search

+20
View File
@@ -3,6 +3,26 @@ name: Arduino Library CI
on: [pull_request, push, repository_dispatch]
jobs:
nmea-tests:
name: NMEA regression tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run host tests with sanitizers
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/nmea-tests"
for test in nmea_validate nmea_fields nmea_utilities; do
echo "Running $test"
g++ -std=c++11 -Wall -Wextra -Werror -g \
-fsanitize=address,undefined -fno-sanitize-recover=all \
-fno-omit-frame-pointer -Isrc src/Adafruit_NMEA.cpp \
"extras/tests/$test/$test.cpp" -o "$RUNNER_TEMP/nmea-tests/$test"
"$RUNNER_TEMP/nmea-tests/$test"
done
build:
runs-on: ubuntu-latest
@@ -0,0 +1,167 @@
/* Host regression for decimal parsing and command construction:
g++ -std=c++11 -Wall -Wextra -Werror -fsanitize=address,undefined -Isrc \
src/Adafruit_NMEA.cpp extras/tests/nmea_utilities/nmea_utilities.cpp \
-o /tmp/nmea_utilities
/tmp/nmea_utilities
*/
#include "Adafruit_NMEA.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void expectNumber(nmea_span_t field, nmea_number_status_t status,
int64_t coefficient = 0, uint8_t decimalPlaces = 0);
static void expectCommand(const char *body, const char *expected);
static void expectRejected(const char *body, size_t length);
int main() {
struct {
const char *text;
int64_t coefficient;
uint8_t decimalPlaces;
} numbers[] = {{"0", 0, 0},
{"-0.000", 0, 3},
{"-123.4500", -1234500, 4},
{"+12", 12, 0},
{"-.5", -5, 1},
{"1.", 1, 0},
{"00012.0300", 120300, 4},
{"4807.038123456", INT64_C(4807038123456), 9},
{"9223372036854775807", INT64_MAX, 0},
{"-9223372036854775808", INT64_MIN, 0},
{"922337203685477580.7", INT64_MAX, 1},
{"-922337203685477580.8", INT64_MIN, 1}};
for (const auto &number : numbers)
expectNumber({number.text, strlen(number.text)}, NMEA_NUMBER_VALID,
number.coefficient, number.decimalPlaces);
puts("PASS: exact decimals, retained zeros, and signed 64-bit limits");
const char *badNumbers[] = {
".", "+", "-", "+.", "--1", "1.2.3", " 1",
"1 ", "1e2", "NaN", "inf", "1,2", "1*00", "9223372036854775808x"};
for (const char *text : badNumbers)
expectNumber({text, strlen(text)}, NMEA_NUMBER_BAD_FORMAT);
const char *overflow[] = {"9223372036854775808", "-9223372036854775809",
"99999999999999999999999999"};
for (const char *text : overflow)
expectNumber({text, strlen(text)}, NMEA_NUMBER_OUT_OF_RANGE);
expectNumber({NULL, 100}, NMEA_NUMBER_MISSING);
expectNumber({"1", 0}, NMEA_NUMBER_EMPTY);
const char embeddedNul[] = {'1', 0, '2'};
expectNumber({embeddedNul, sizeof(embeddedNul)}, NMEA_NUMBER_BAD_FORMAT);
const char highByte[] = {'1', (char)0x80};
expectNumber({highByte, sizeof(highByte)}, NMEA_NUMBER_BAD_FORMAT);
puts("PASS: missing, empty, malformed, and overflowing numbers are distinct");
const char bounded[] = {'-', '1', '.', '2'};
expectNumber({bounded, sizeof(bounded)}, NMEA_NUMBER_VALID, -12, 1);
expectNumber({"12x", 2}, NMEA_NUMBER_VALID, 12, 0);
char fraction[258];
memset(fraction, '0', sizeof(fraction));
fraction[1] = '.';
expectNumber({fraction, 257}, NMEA_NUMBER_VALID, 0, 255);
expectNumber({fraction, sizeof(fraction)}, NMEA_NUMBER_OUT_OF_RANGE);
char zeros[300];
memset(zeros, '0', sizeof(zeros));
expectNumber({zeros, sizeof(zeros)}, NMEA_NUMBER_VALID);
puts("PASS: bounded numeric input and decimal-place limits");
expectCommand("PQTMVERNO", "$PQTMVERNO*58\r\n");
expectCommand("PQTMVERNO,", "$PQTMVERNO,*74\r\n");
expectCommand("PAIR002", "$PAIR002*38\r\n");
expectCommand("PAIR001,004,0", "$PAIR001,004,0*3F\r\n");
expectCommand("PMTK220,1000", "$PMTK220,1000*1F\r\n");
puts("PASS: known command checksums and every output capacity boundary");
const char *badBodies[] = {"", ",", ",A", "A B", "$A", "!A",
"A*41", "A\r", "A\n", "A,\t", "A,\x7f", "A,\x80"};
for (const char *body : badBodies)
expectRejected(body, strlen(body));
expectRejected(NULL, 10);
expectRejected("A", SIZE_MAX);
expectRejected(embeddedNul, sizeof(embeddedNul));
assert(Adafruit_NMEA::buildCommand(NULL, 100, "A", 1) == 0);
puts("PASS: malformed commands and invalid arguments produce no partial "
"output");
char overlap[40];
memset(overlap, 'A', sizeof(overlap));
assert(Adafruit_NMEA::buildCommand(overlap, 20, overlap + 4, 7) == 0);
assert(overlap[0] == 0);
for (size_t i = 1; i < sizeof(overlap); i++)
assert(overlap[i] == 'A');
memset(overlap, 'A', sizeof(overlap));
assert(Adafruit_NMEA::buildCommand(overlap + 4, 20, overlap, 7) == 0);
for (size_t i = 0; i < sizeof(overlap); i++) {
if (i == 4)
assert(overlap[i] == 0);
else
assert(overlap[i] == 'A');
}
memset(overlap, 'A', sizeof(overlap));
assert(Adafruit_NMEA::buildCommand(overlap, 20, overlap, 7) == 0);
puts("PASS: overlapping source and destination ranges are rejected");
char adjacent[13];
memcpy(adjacent + 10, "A,1", 3);
assert(Adafruit_NMEA::buildCommand(adjacent, 10, adjacent + 10, 3) == 9);
assert(strcmp(adjacent, "$A,1*5C\r\n") == 0);
assert(memcmp(adjacent + 10, "A,1", 3) == 0);
memcpy(adjacent, "A,1", 3);
assert(Adafruit_NMEA::buildCommand(adjacent + 3, 10, adjacent, 3) == 9);
assert(strcmp(adjacent + 3, "$A,1*5C\r\n") == 0);
assert(memcmp(adjacent, "A,1", 3) == 0);
puts("PASS: adjacent buffers and nonterminated command bodies are supported");
return 0;
}
static void expectNumber(nmea_span_t field, nmea_number_status_t status,
int64_t coefficient, uint8_t decimalPlaces) {
nmea_decimal_t result = Adafruit_NMEA::parseDecimal(field);
assert(result.status == status);
assert(result.coefficient == coefficient);
assert(result.decimalPlaces == decimalPlaces);
}
static void expectCommand(const char *body, const char *expected) {
size_t required = strlen(expected) + 1;
for (size_t capacity = 0; capacity <= required + 1; capacity++) {
size_t allocated = capacity;
if (!allocated)
allocated = 1;
char *output = (char *)malloc(allocated);
assert(output != NULL);
memset(output, '?', allocated);
size_t length =
Adafruit_NMEA::buildCommand(output, capacity, body, strlen(body));
if (capacity < required) {
assert(length == 0);
if (capacity)
assert(output[0] == 0);
else
assert(output[0] == '?');
for (size_t i = 1; i < allocated; i++)
assert(output[i] == '?');
} else {
assert(length == required - 1);
assert(strcmp(output, expected) == 0);
assert(Adafruit_NMEA::validate(output, length).status ==
NMEA_FRAME_VALID);
if (capacity > required)
assert(output[required] == '?');
}
free(output);
}
}
static void expectRejected(const char *body, size_t length) {
char output[64];
memset(output, '?', sizeof(output));
assert(Adafruit_NMEA::buildCommand(output, sizeof(output), body, length) ==
0);
assert(output[0] == 0);
for (size_t i = 1; i < sizeof(output); i++)
assert(output[i] == '?');
}
+165 -1
View File
@@ -1,13 +1,14 @@
/**************************************************************************/
/*!
@file Adafruit_NMEA.cpp
@brief Hardware-independent NMEA sentence validation.
@brief Hardware-independent NMEA sentence and field utilities.
Written for Adafruit Industries. BSD license; see license.txt.
*/
/**************************************************************************/
#include "Adafruit_NMEA.h"
#include <string.h>
/**************************************************************************/
/*!
@@ -151,3 +152,166 @@ nmea_span_t Adafruit_NMEA::nextField(nmea_span_t &remaining) {
}
return field;
}
/**************************************************************************/
/*!
@brief Convert a complete field to an exact signed decimal value.
@param field Borrowed readable text, with no NUL terminator required.
@return Status, integer coefficient, and decimal-place count. Both numeric
members are zero on failure. NULL data is MISSING; zero length is EMPTY.
Accepts an optional sign, digits, and at most one decimal point. At least
one digit is required. Whitespace, exponents, and non-digit suffixes are
rejected. The supplied fractional digit count is retained, including zeros.
Coefficients must fit int64_t and decimal-place counts must fit uint8_t.
Malformed syntax takes precedence over overflow. No rounding, floating-point
conversion, allocation, or input modification occurs.
*/
/**************************************************************************/
nmea_decimal_t Adafruit_NMEA::parseDecimal(nmea_span_t field) {
nmea_decimal_t result = {NMEA_NUMBER_BAD_FORMAT, 0, 0};
if (!field.data) {
result.status = NMEA_NUMBER_MISSING;
return result;
}
if (!field.length) {
result.status = NMEA_NUMBER_EMPTY;
return result;
}
size_t start = 0;
bool negative = false;
if (field.data[0] == '-' || field.data[0] == '+') {
negative = field.data[0] == '-';
start = 1;
}
// Accumulate negatively: INT64_MIN has no positive int64_t counterpart.
int64_t limit = -INT64_MAX;
if (negative)
limit = INT64_MIN;
int64_t cutoff = limit / 10;
uint8_t lastDigitLimit = (uint8_t)(-(limit % 10));
int64_t coefficient = 0;
uint8_t decimalPlaces = 0;
bool decimalPoint = false;
bool hasDigit = false;
bool overflow = false;
for (size_t i = start; i < field.length; i++) {
char c = field.data[i];
if (c == '.' && !decimalPoint) {
decimalPoint = true;
continue;
}
if (c < '0' || c > '9')
return result;
hasDigit = true;
uint8_t digit = c - '0';
if (decimalPoint) {
if (decimalPlaces == UINT8_MAX)
overflow = true;
else
decimalPlaces++;
}
if (!overflow) {
if (coefficient < cutoff ||
(coefficient == cutoff && digit > lastDigitLimit))
overflow = true;
else
coefficient = coefficient * 10 - digit;
}
}
if (!hasDigit)
return result;
if (overflow) {
result.status = NMEA_NUMBER_OUT_OF_RANGE;
return result;
}
result.status = NMEA_NUMBER_VALID;
result.coefficient = coefficient;
if (!negative)
result.coefficient = -coefficient;
result.decimalPlaces = decimalPlaces;
return result;
}
/**************************************************************************/
/*!
@brief Build a checksummed command in caller-provided storage.
@param output Writable buffer for the complete command and NUL terminator.
@param capacity Size of output in bytes, including space for NUL.
@param body Readable address and optional comma-separated fields, without
'$', '!', '*', control characters, or non-ASCII bytes. No NUL is required.
@param bodyLength Number of bytes in body, excluding any NUL terminator.
@return Bytes written excluding NUL, or zero on invalid input, overlapping
buffers, or insufficient capacity. On failure, output[0] is cleared if
output is non-NULL and capacity is nonzero; no other output bytes are
changed.
Produces "$<body>*HH\r\n" followed by NUL, using uppercase checksum digits.
Requires bodyLength + NMEA_COMMAND_OVERHEAD bytes of storage. The address
must be nonempty and ASCII alphanumeric. Zero-field commands are allowed.
Arguments, capacity, and body syntax are checked before constructing output.
No heap allocation or transport I/O occurs.
*/
/**************************************************************************/
size_t Adafruit_NMEA::buildCommand(char *output, size_t capacity,
const char *body, size_t bodyLength) {
bool valid = output && body && bodyLength &&
capacity >= NMEA_COMMAND_OVERHEAD &&
bodyLength <= capacity - NMEA_COMMAND_OVERHEAD;
if (valid) {
// Compare address differences so checking overlap cannot overflow an end
// address. Reject overlap with the caller's entire writable output span.
uintptr_t outputAddress = (uintptr_t)output;
uintptr_t bodyAddress = (uintptr_t)body;
if (outputAddress <= bodyAddress)
valid = bodyAddress - outputAddress >= capacity;
else
valid = outputAddress - bodyAddress >= bodyLength;
}
uint8_t checksum = 0;
bool inAddress = true;
if (valid) {
for (size_t i = 0; i < bodyLength; i++) {
uint8_t c = (uint8_t)body[i];
if (c < ' ' || c > '~' || c == '$' || c == '!' || c == '*') {
valid = false;
break;
}
if (inAddress) {
if (c == ',' && i > 0) {
inAddress = false;
} else if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9'))) {
valid = false;
break;
}
}
checksum ^= c;
}
}
if (!valid) {
if (output && capacity)
output[0] = '\0';
return 0;
}
output[0] = '$';
memcpy(output + 1, body, bodyLength);
size_t end = bodyLength + 1;
output[end++] = '*';
uint8_t digits[] = {(uint8_t)(checksum / 16), (uint8_t)(checksum % 16)};
for (uint8_t i = 0; i < 2; i++) {
if (digits[i] < 10)
output[end++] = '0' + digits[i];
else
output[end++] = 'A' + digits[i] - 10;
}
output[end++] = '\r';
output[end++] = '\n';
output[end] = '\0';
return end;
}
+24 -1
View File
@@ -1,7 +1,7 @@
/**************************************************************************/
/*!
@file Adafruit_NMEA.h
@brief Hardware-independent NMEA sentence validation.
@brief Hardware-independent NMEA sentence and field utilities.
Written for Adafruit Industries. BSD license; see license.txt.
*/
@@ -13,6 +13,8 @@
#include <stddef.h>
#include <stdint.h>
#define NMEA_COMMAND_OVERHEAD 7 ///< Bytes for '$', '*HH', CR/LF, and NUL.
/** 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 {
@@ -43,11 +45,32 @@ typedef struct {
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;
/// Hardware-independent NMEA utilities shared by receiver classes.
class Adafruit_NMEA {
public:
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);
};
#endif // ADAFRUIT_NMEA_H