Integrate upstream GPS PRs 3624, 3625 and 3626

Port the guarded NMEA buffer, validated RMC clock samples and coordinate bounds from meshcore-dev/MeshCore#3624, #3625 and #3626. Adapt clock admission to retain this branch's signed date limits, bounded loop work, tracker acquisitions, sample consumption, diagnostics and periodic settings.

Upstream-PR: https://github.com/meshcore-dev/MeshCore/pull/3624
Upstream-Commit: 1ac389bbef0084623a3115c526156ae8f414d3ea
Upstream-PR: https://github.com/meshcore-dev/MeshCore/pull/3625
Upstream-Commit: 72ed4dcf08869c6900ef579e7af1b28471a86895
Upstream-PR: https://github.com/meshcore-dev/MeshCore/pull/3626
Upstream-Commit: 499727a71c564a7b5d451b8f480d940e185fe742

Co-authored-by: nullrouten0 <201262769+nullrouten0@users.noreply.github.com>
This commit is contained in:
mikecarperandnullrouten0 committed 2026-10-17 19:53:08 -07:00
1 parent 55f67f9062
commit 3db4582bbb
14 files changed
+1054 -52

No files matched your search

+3
View File
@@ -226,6 +226,9 @@ jobs:
- name: Run Unit Tests
run: pio test -e native -e native_kiss_modem -e native_ota_channel -vv
- name: Verify real GPS NMEA parser and provider with address and undefined sanitizers
run: pio test -e native_gps -vv
- name: Verify parser, delivery, storage, permissions, clock and radio stability
run: |
python3 -B test/test_protocol_parser_bounds.py -v
+26 -1
View File
@@ -488,7 +488,7 @@ build_flags = -std=c++17
-D OTA_FETCH_PIPELINE=4
-D OTA_TRANSPORT_DEFLATE_TEST=1
test_build_src = yes
test_ignore = test_kiss_modem, test_ota_channel
test_ignore = test_kiss_modem, test_ota_channel, test_gps_nmea
build_src_filter =
-<*>
+<../src/Utils.cpp>
@@ -553,3 +553,28 @@ build_src_filter =
-<*>
lib_deps =
google/googletest @ 1.17.0
; Isolated GPS suite: execute the real provider with the same reviewed parser
; middleware as firmware. Keep the existing native suite and source filter
; unchanged, and use only this suite's scoped DateTime hardware adapter.
[env:native_gps]
platform = native
test_framework = googletest
extra_scripts = pre:scripts/micronmea_time_fix.py
lib_compat_mode = off
build_flags = -std=c++17
-I test/test_gps_nmea
-I test/mocks
-I src
-fsanitize=address,undefined
-fno-sanitize-recover=all
-fno-omit-frame-pointer
-fno-pie
-Wl,-no-pie
test_build_src = yes
test_filter = test_gps_nmea
build_src_filter =
-<*>
lib_deps =
google/googletest @ 1.17.0
stevemarple/MicroNMEA @ 2.0.6
+84 -37
View File
@@ -44,7 +44,14 @@
#endif
class MicroNMEALocationProvider : public LocationProvider {
char _nmeaBuffer[100];
public:
// Keep MicroNMEA's accepted sentence size unchanged. Its fixed-width
// coordinate skips can look past a malformed sentence's final NUL.
static constexpr uint8_t NMEA_BUFFER_LEN = 100;
static constexpr size_t NMEA_BUFFER_GUARD = 16;
private:
char _nmeaBuffer[NMEA_BUFFER_LEN + NMEA_BUFFER_GUARD] = {};
MicroNMEA nmea;
mesh::RTCClock* _clock;
Stream* _gps_serial;
@@ -83,40 +90,74 @@ class MicroNMEALocationProvider : public LocationProvider {
bool _fresh_clock_sample = false;
uint32_t _clock_sample_epoch = 0;
uint32_t _clock_sample_at = 0;
uint32_t _clock_run_epoch = 0;
uint32_t _clock_run_at = 0;
// Permit one validation period plus scheduling jitter, not retained fixes
// across receiver sleep or a paused foreground loop.
static constexpr uint32_t ClockSampleMaxAgeMillis = 2000;
static bool hasCompleteRmcClockFields(const char* sentence) {
// MicroNMEA retains previous date/time fields when an RMC leaves them
// empty, and its numeric parser assumes complete digit fields. Check
// the current sentence before treating its parsed UTC as fresh.
static bool isDigits(const char* text, size_t length) {
for (size_t i = 0; i < length; ++i) {
if (text[i] < '0' || text[i] > '9') return false;
}
return true;
}
static uint8_t twoDigits(const char* text) {
return uint8_t((text[0] - '0') * 10 + text[1] - '0');
}
static uint32_t rmcEpoch(const char* sentence) {
// Read only this checksum-covered RMC's text, never a mixture of
// MicroNMEA's retained date/time or position fields.
const char* star = strchr(sentence, '*');
const char* time = strchr(sentence, ',');
if (time == NULL) return false;
if (star == NULL || time == NULL || time >= star
|| time != sentence + 6 || strncmp(sentence + 3, "RMC", 3)) return 0;
++time;
const char* end = strchr(time, ',');
if (end == NULL || end - time < 6) return false;
for (unsigned i = 0; i < 6; ++i) {
if (time[i] < '0' || time[i] > '9') return false;
}
if (end == NULL || end >= star || end - time < 6
|| !isDigits(time, 6)) return 0;
if (end - time > 6) {
if (time[6] != '.' || end - time == 7) return false;
for (const char* digit = time + 7; digit < end; ++digit) {
if (*digit < '0' || *digit > '9') return false;
}
if (time[6] != '.' || end - time == 7
|| !isDigits(time + 7, size_t(end - time - 7))) return 0;
}
// UTC date is RMC field 9, after status, position, speed and course.
const char* date = end + 1;
for (unsigned field = 2; field < 9; ++field) {
while (*date != '\0' && *date != ',' && *date != '*') ++date;
if (*date != ',') return false;
const char* status = end + 1;
if (star - status < 2 || status[0] != 'A' || status[1] != ',') return 0;
const char* date = status + 2;
for (unsigned field = 3; field <= 8; ++field) {
date = strchr(date, ',');
if (date == NULL || date >= star) return 0;
++date;
}
for (unsigned i = 0; i < 6; ++i) {
if (date[i] < '0' || date[i] > '9') return false;
if (star - date < 6 || !isDigits(date, 6)
|| (date[6] != ',' && date[6] != '*')) return 0;
const uint16_t year = 2000 + twoDigits(date + 4);
const uint8_t month = twoDigits(date + 2), day = twoDigits(date);
const uint8_t hour = twoDigits(time), minute = twoDigits(time + 2),
second = twoDigits(time + 4);
if (!mesh::gps::isValidNmeaDateTime(year, month, day, hour, minute, second)) return 0;
return DateTime(year, month, day, hour, minute, second).unixtime();
}
bool noteClockSample(uint32_t epoch, uint32_t observed_at) {
if (time_valid != 0) {
const int32_t gps_seconds = int32_t(epoch) - int32_t(_clock_run_epoch);
// Repeats do not count and must not refresh the sample's age.
if (gps_seconds == 0) return false;
const int32_t wall_seconds = int32_t((observed_at - _clock_run_at) / 1000);
const int32_t drift = gps_seconds - wall_seconds;
if (gps_seconds > 0 && drift >= -2 && drift <= 2) {
if (time_valid < 3) ++time_valid;
} else {
time_valid = 1;
}
} else {
time_valid = 1;
}
return date[6] == ',' || date[6] == '*';
_clock_run_epoch = epoch;
_clock_run_at = observed_at;
return true;
}
static uint32_t ageMs(unsigned long timestamp) {
@@ -133,7 +174,7 @@ class MicroNMEALocationProvider : public LocationProvider {
public :
MicroNMEALocationProvider(Stream& ser, mesh::RTCClock* clock = NULL, int pin_reset = GPS_RESET, int pin_en = GPS_EN,RefCountedDigitalPin* peripher_power=NULL) :
nmea(_nmeaBuffer, sizeof(_nmeaBuffer)), _clock(clock), _gps_serial(&ser), _peripher_power(peripher_power), _pin_reset(pin_reset), _pin_en(pin_en) {
nmea(_nmeaBuffer, NMEA_BUFFER_LEN), _clock(clock), _gps_serial(&ser), _peripher_power(peripher_power), _pin_reset(pin_reset), _pin_en(pin_en) {
if (_pin_reset != -1) {
pinMode(_pin_reset, OUTPUT);
digitalWrite(_pin_reset, GPS_RESET_ACTIVE);
@@ -235,7 +276,12 @@ public :
return alt;
}
long satellitesCount() override { return nmea.getNumSatellites(); }
bool isValid() override { return nmea.isValid(); }
bool isValid() override {
if (!nmea.isValid()) return false;
const long latitude = nmea.getLatitude(), longitude = nmea.getLongitude();
return latitude >= -90000000L && latitude <= 90000000L
&& longitude >= -180000000L && longitude <= 180000000L;
}
bool clearPositionFix() override {
nmea.clear();
time_valid = 0;
@@ -330,12 +376,10 @@ public :
// retained RMC date for a new clock write, especially at
// midnight. Keep the complete UTC sample with its age.
if (parsed && strcmp(message_id, "RMC") == 0) {
const long epoch = isValid()
&& hasCompleteRmcClockFields(nmea.getSentence())
? getTimestamp() : 0;
const uint32_t epoch = rmcEpoch(nmea.getSentence());
_fresh_clock_sample = epoch > 0;
if (_fresh_clock_sample) {
_clock_sample_epoch = static_cast<uint32_t>(epoch);
_clock_sample_epoch = epoch;
_clock_sample_at = millis();
} else {
time_valid = 0;
@@ -347,12 +391,15 @@ public :
}
}
if (!isValid()) time_valid = 0;
// Keep each loop's byte budget, but validate only after its queued
// batch is drained so a partial later sentence cannot hide a newer
// clock sample behind an earlier one in the same UART backlog.
if (_gps_serial->available()) return;
const uint32_t now = millis();
if (static_cast<int32_t>(now - static_cast<uint32_t>(next_check)) > 0) {
next_check = now + 1000UL;
// Re-enable time sync periodically when GPS has valid fix
// Re-enable periodically; writes still need fresh agreeing UTC.
if (!_time_sync_needed && _clock != NULL
&& static_cast<uint32_t>(millis() - _last_time_sync)
>= periodicTimeSyncIntervalMillis()) {
@@ -360,17 +407,17 @@ public :
}
const bool fresh_clock = _fresh_clock_sample
&& static_cast<uint32_t>(now - _clock_sample_at) <= ClockSampleMaxAgeMillis;
if (!isValid() || satellitesCount() < 5) {
time_valid = 0;
} else if (fresh_clock) {
if (time_valid < 3) ++time_valid;
} else if (_fresh_time_sync_required) {
const bool advancing_clock = fresh_clock
&& noteClockSample(_clock_sample_epoch, _clock_sample_at);
if (!advancing_clock && _fresh_time_sync_required) {
// Tracker acquisitions retain their consecutive-sample rule.
// Normal receivers can report more slowly than this 1s poll.
time_valid = 0;
}
_fresh_clock_sample = false;
if (_time_sync_needed && time_valid >= 3 && fresh_clock) {
// Consume even while no sync is requested. An expired observation
// must never become fresh again after a full millis() rollover.
if (_time_sync_needed && time_valid >= 3 && advancing_clock) {
if (_clock != NULL) {
_clock->setCurrentTime(_clock_sample_epoch);
markTimeSyncApplied();
+3
View File
@@ -34,6 +34,9 @@ inline void delay(uint32_t ms) {
g_mock_millis += ms;
}
// Suites with busy-waiting production methods supply advancing mock time.
void yield();
inline char* ltoa(long value, char* dest, int base) {
if (base == 10) {
std::snprintf(dest, 16, "%ld", value);
+7
View File
@@ -41,6 +41,13 @@ public:
virtual int16_t getNoiseFloor() { return -120; }
};
class RTCClock {
public:
virtual ~RTCClock() = default;
virtual uint32_t getCurrentTime() = 0;
virtual void setCurrentTime(uint32_t time) = 0;
};
class MainBoard {
public:
virtual ~MainBoard() = default;
@@ -80,6 +80,13 @@ std::string pair(const std::string& time,
return gga(time) + rmc(time, date);
}
std::string noonPlus(unsigned seconds) {
char time[16] = {};
std::snprintf(time, sizeof(time), "%02u%02u%02u.00",
12 + seconds / 3600, seconds / 60 % 60, seconds % 60);
return time;
}
class GpsRig {
bool _first = true;
uint32_t _last_check = 0;
@@ -184,11 +191,46 @@ TEST(GpsClockFreshness, FiveSecondReceiverRetainsProgressThroughEmptyWindows) {
EXPECT_EQ(NOON_2026_10_17 + 10, rig.rtc.writes.back().after);
}
TEST(GpsClockFreshness, SeparateCompleteObservationsMayShareUtcResolution) {
TEST(GpsClockFreshness, RepeatedUtcSecondsCannotValidateAStoppedReceiver) {
GpsRig rig;
for (unsigned i = 0; i < 3; ++i) rig.window(pair("120000.00"));
for (unsigned i = 0; i < 6; ++i) rig.window(pair("120000.00"));
EXPECT_TRUE(rig.rtc.writes.empty());
EXPECT_TRUE(rig.gps.waitingTimeSync());
EXPECT_EQ(0U, rig.gps.getTimeSyncGeneration());
}
TEST(GpsClockFreshness, RmcOnlyClockDoesNotRequireGgaSatelliteCount) {
GpsRig rig;
for (unsigned second = 0; second < 3; ++second) {
rig.window(rmc(noonPlus(second)));
EXPECT_EQ(0, rig.gps.satellitesCount());
if (second < 2) {
EXPECT_TRUE(rig.rtc.writes.empty());
}
}
ASSERT_EQ(1U, rig.rtc.writes.size());
EXPECT_EQ(NOON_2026_10_17, rig.rtc.writes.back().after);
EXPECT_EQ(NOON_2026_10_17 + 2, rig.rtc.writes.back().after);
EXPECT_EQ(1U, rig.gps.getTimeSyncGeneration());
}
TEST(GpsClockFreshness, StartupOutlierRestartsAgreementBeforeApplyingClock) {
for (unsigned outlier : {0U, 1U, 2U}) {
SCOPED_TRACE(outlier);
GpsRig rig;
for (unsigned second = 0; second < 3; ++second) {
rig.window(pair(noonPlus(second), second == outlier ? "171027" : "171026"));
EXPECT_TRUE(rig.rtc.writes.empty());
EXPECT_EQ(0U, rig.gps.getTimeSyncGeneration());
}
// Every outlier position leaves fewer than three agreeing samples. A
// complete new run must recover without ever writing the outlier's year.
for (unsigned second = 3; second <= 5; ++second) {
rig.window(pair(noonPlus(second)));
EXPECT_EQ(second >= outlier + 3 ? 1U : 0U, rig.rtc.writes.size());
}
ASSERT_EQ(1U, rig.rtc.writes.size());
EXPECT_EQ(NOON_2026_10_17 + outlier + 3, rig.rtc.writes.back().after);
}
}
TEST(GpsClockFreshness, MultipleQueuedRmcSentencesCountOnlyOneValidation) {
@@ -243,6 +285,23 @@ TEST(GpsClockFreshness, CandidateCannotSurviveThirtyMinuteLoopPause) {
EXPECT_EQ(NOON_2026_10_17 + 1804, rig.rtc.writes.back().after);
}
TEST(GpsClockFreshness, ExpiredCandidateCannotReappearAfterAFullMillisCycle) {
GpsRig rig;
rig.acquire();
ASSERT_EQ(1U, rig.rtc.writes.size());
rig.receiveWithoutCheck(pair("120003.00"));
const uint32_t observed_at = millis();
// Three forward steps below INT32_MAX model one complete uint32 millis
// cycle, without billions of polling calls. The first poll expires and
// consumes the old candidate while making periodic synchronization due.
rig.checkAt(observed_at + 0x70000000UL);
rig.checkAt(observed_at + 0xe0000000UL);
rig.checkAt(observed_at + 1001UL);
EXPECT_EQ(1U, rig.rtc.writes.size());
EXPECT_EQ(1U, rig.gps.getTimeSyncGeneration());
EXPECT_TRUE(rig.gps.waitingTimeSync());
}
TEST(GpsClockFreshness, CandidateAgeAllowsTwoSecondsButNotAnOlderObservation) {
for (uint32_t age : {2000U, 2001U}) {
SCOPED_TRACE(age);
@@ -322,6 +381,22 @@ TEST(GpsClockFreshness, TrackerRequiresFreshObservationsInConsecutiveWindows) {
EXPECT_EQ(NOON_2026_10_17 + 5, rig.rtc.writes.back().after);
}
TEST(GpsClockFreshness, TrackerRepeatedEpochBreaksConsecutiveValidation) {
GpsRig rig;
rig.gps.beginFreshTimeSync();
rig.window(pair("120000.00"));
rig.window(pair("120001.00"));
// A checksum-valid repeat is not an advancing clock observation, even when
// its UART arrival is recent. Tracker acquisition must restart its run.
rig.window(pair("120001.00"));
rig.window(pair("120003.00"));
rig.window(pair("120004.00"));
EXPECT_TRUE(rig.rtc.writes.empty());
rig.window(pair("120005.00"));
ASSERT_EQ(1U, rig.rtc.writes.size());
EXPECT_EQ(NOON_2026_10_17 + 5, rig.rtc.writes.back().after);
}
TEST(GpsClockFreshness, ManualSyncResetsCountAndUnconsumedCandidate) {
GpsRig rig;
rig.window(pair("120000.00"));
@@ -383,7 +458,7 @@ TEST(GpsClockFreshness, WarmValidationHistorySaturatesInsteadOfWrapping) {
rig.acquire();
ASSERT_EQ(1U, rig.rtc.writes.size());
// Three initial observations plus 253 more would wrap an unsaturated byte.
for (unsigned i = 0; i < 253; ++i) rig.window(pair("120003.00"));
for (unsigned i = 0; i < 253; ++i) rig.window(pair(noonPlus(3 + i)));
rig.idleUntilPeriodicDue();
EXPECT_EQ(1U, rig.rtc.writes.size());
rig.window(pair("123003.00"));
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <stdint.h>
// Minimal DateTime stand-in for native tests. unixtime() matches RTClib for
// valid dates from 2000 to 2099, the range RTClib supports (seconds since
// 1970-01-01 UTC). Out-of-range fields map to a deterministic value instead of
// indexing past the month table, so tests can compare two parses of the same
// bytes; such values are not what RTClib would return.
class DateTime {
uint16_t _year;
uint8_t _month, _day, _hour, _minute, _second;
static bool leap(uint16_t year) {
return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
}
public:
DateTime(uint16_t year, uint8_t month, uint8_t day, uint8_t hour,
uint8_t minute, uint8_t second)
: _year(year), _month(month), _day(day), _hour(hour),
_minute(minute), _second(second) {}
uint32_t unixtime() const {
uint32_t days = 0;
for (uint16_t year = 1970; year < _year; ++year)
days += leap(year) ? 366 : 365;
if (_month >= 1 && _month <= 12) {
const uint8_t month_days[] = {31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31};
for (uint8_t month = 1; month < _month; ++month)
days += month_days[month - 1] + (month == 2 && leap(_year) ? 1 : 0);
days += _day - 1;
} else {
days += 31UL * _month + _day; // deterministic, not calendar-correct
}
return days * 86400UL + _hour * 3600UL + _minute * 60UL + _second;
}
};
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// Shared helpers for the native GPS/NMEA tests: the real
// MicroNMEALocationProvider and MicroNMEA, fed through a mock UART.
#include <gtest/gtest.h>
#include <MicroNMEA.h>
#include <helpers/sensors/MicroNMEALocationProvider.h>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
struct MockSerial : Stream {
std::deque<char> queue;
int available() override { return (int)queue.size(); }
int read() override {
if (queue.empty()) return -1;
char c = queue.front();
queue.pop_front();
return (unsigned char)c;
}
void feed(const std::string& text) {
for (char c : text) queue.push_back(c);
}
};
// "$" + body + "*" + two hex digits, the checksum MicroNMEA expects.
inline std::string withChecksum(const std::string& body) {
uint8_t sum = 0;
for (char c : body) sum ^= (uint8_t)c;
char hex[4];
snprintf(hex, sizeof(hex), "%02X", sum);
return "$" + body + "*" + hex;
}
inline void feedAndLoop(MicroNMEALocationProvider& gps, MockSerial& serial,
const std::string& line) {
serial.feed(line + "\r\n");
// One production loop consumes at most GPS_LOOP_MAX_BYTES. Drain only the
// finite fixture input so the 99/100-byte boundary cases reach a terminator.
size_t loops = 0;
while (serial.available()) {
ASSERT_LT(loops++, line.size() + 4);
gps.loop();
}
}
inline constexpr const char* kGoodRmc =
"GPRMC,120034.00,A,4716.1234,N,12233.5678,W,0.12,34.5,171026,003.1,W,A";
inline constexpr const char* kGoodGga =
"GPGGA,120034.00,4716.1234,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,";
+201
View File
@@ -0,0 +1,201 @@
#include "gps_test_support.h"
#include <memory>
#include <new>
#include <random>
namespace {
constexpr size_t kLen = MicroNMEALocationProvider::NMEA_BUFFER_LEN;
constexpr size_t kGuard = MicroNMEALocationProvider::NMEA_BUFFER_GUARD;
// The library's checksum test stops at the first '*', while parseTime()
// skips a fixed nine characters. A sentence whose checksum covers only
// "GPRMC,1" therefore passes, and the bytes after the checksum are parsed as
// fields. Both sentences below are 99 characters, the longest the buffer
// holds, so the sentence NUL sits at index 99 and the last fixed-width reads
// land past it.
// Validity flag at index 97: processRMC steps onto the NUL and the latitude
// parse reads indices 99 and 100.
std::string overreadAtLatitude() {
std::string s = withChecksum("GPRMC,1"); // 11 chars
while (s.size() < 96) s.push_back('X');
s += ",AB"; // ',' @96, 'A' @97, 'B' @98
return s;
}
// The production UTC-field patch rejects overreadAtLatitude()'s short time.
// A complete time still reaches the unpatched degree/hemisphere lookahead:
// parseDegreeMinute consumes the first '*', skipField finds ',' at index 96,
// then the hemisphere skip advances onto the NUL at 99 before longitude reads.
std::string overreadAfterCompleteTime() {
std::string s = withChecksum("GPRMC,120034.00,A,1");
while (s.size() < 96) s.push_back('X');
s += ",XB";
return s;
}
// Date field at index 98: parseDate reads six bytes from index 98 (to 103)
// and then looks for the next field at index 104.
std::string overreadAtDate() {
std::string s = withChecksum("GPRMC,1"); // idx 0..10
s += "AAAAA"; // rest of the 9-char time field
s += ",A,4716.1,N,"; // validity, latitude, hemisphere
const std::string tail = ",W,0.1,0.2,"; // longitude end .. course
s += std::string(98 - tail.size() - s.size(), 'X'); // longitude filler
s += tail;
s += "1"; // date field starts at idx 98
return s;
}
// A line longer than the buffer: the library keeps overwriting its last byte.
std::string longJunkLine() { return std::string(180, '7'); }
// Parse the same bytes with a library instance whose buffer is followed by
// NUL bytes, which is what the provider's guard tail must guarantee.
struct ReferenceParse {
char buffer[100 + 64];
MicroNMEA nmea;
ReferenceParse() : buffer{}, nmea(buffer, 100) {}
void feed(const std::string& line) {
for (char c : line) nmea.process(c);
nmea.process('\n');
}
};
// Build the provider in memory that is not zero, so the test does not rely on
// a freshly zeroed stack or heap to make the guard tail look clean.
struct DirtyProvider {
alignas(MicroNMEALocationProvider) unsigned char storage[sizeof(MicroNMEALocationProvider)];
MicroNMEALocationProvider* gps;
explicit DirtyProvider(Stream& serial) {
memset(storage, 0xA5, sizeof(storage));
gps = new (storage) MicroNMEALocationProvider(serial, nullptr, -1, -1);
}
~DirtyProvider() { gps->~MicroNMEALocationProvider(); }
};
// Checksum-valid sentences whose fields run up to the end of the buffer.
// Field values stay short: MicroNMEA's parseFloat() also overflows a signed
// long on 19+ digit fields, a separate library bug this suite does not cover.
std::string malformedNearEnd(std::mt19937& rng) {
static const char alphabet[] = ",,,,AANNWWSSEE0123456789..**X";
std::string s = withChecksum(rng() % 2 ? "GPRMC,1" : "GPGGA,1");
const size_t length = 80 + rng() % 20; // 80..99 characters
size_t run = 0;
while (s.size() < length) {
char c = alphabet[rng() % (sizeof(alphabet) - 1)];
run = (c >= '0' && c <= '9') ? run + 1 : 0;
if (run > 12) c = ',', run = 0;
s.push_back(c);
}
return s;
}
} // namespace
TEST(MicroNMEAProvider, SentenceLongerThanTheBufferIsStillRejected) {
// The guard tail must not change which sentences are accepted: a sentence
// of 100 or more characters is truncated and fails its checksum, as before.
std::string body = kGoodRmc;
while (body.size() < 100 - 4) body.push_back('Z');
const std::string sentence = withChecksum(body);
ASSERT_EQ(100u, sentence.size());
g_mock_millis = 1000;
MockSerial serial;
MicroNMEALocationProvider gps(serial, nullptr, -1, -1);
feedAndLoop(gps, serial, sentence);
EXPECT_FALSE(gps.isValid());
}
TEST(MicroNMEALibrary, NeverWritesPastTheLengthItIsGiven) {
// The premise of the guard tail: MicroNMEA 2.0.6 clamps writes at
// buffer[len - 1]. Long, truncated and malformed lines must leave the tail
// untouched, or it would stop being a run of NUL bytes.
std::unique_ptr<char[]> buffer(new char[kLen + kGuard]());
MicroNMEA nmea(buffer.get(), kLen);
std::mt19937 rng(7);
for (int i = 0; i < 2000; ++i) {
std::string line = (i % 3 == 0) ? longJunkLine() : malformedNearEnd(rng);
for (size_t k = rng() % 120; k > 0; --k) line.push_back((char)(' ' + rng() % 95));
for (char c : line) nmea.process(c);
nmea.process((i % 2) ? '\n' : '\r');
for (size_t k = kLen; k < kLen + kGuard; ++k) {
ASSERT_EQ(0, buffer[k]) << "tail byte " << k << " written at line " << i;
}
}
}
TEST(MicroNMEALibrary, ReadsStayInsideTheGuardTail) {
// Heap buffer of exactly the size the provider reserves. With the address
// sanitizer on, any parser read past the guard aborts this test.
std::unique_ptr<char[]> buffer(new char[kLen + kGuard]());
MicroNMEA nmea(buffer.get(), kLen);
auto feed = [&nmea](const std::string& line) {
for (char c : line) nmea.process(c);
nmea.process('\n');
};
feed(overreadAtLatitude());
feed(overreadAtDate());
feed(overreadAfterCompleteTime());
std::mt19937 rng(11);
for (int i = 0; i < 20000; ++i) feed(malformedNearEnd(rng));
SUCCEED();
}
TEST(MicroNMEAProvider, MalformedSentenceParsesAsIfFollowedByNulBytes) {
// Without the guard tail the latitude parse reads the first byte of the
// MicroNMEA object that follows the buffer (the buffer length, 100), so the
// result depends on object layout. With it, the provider must parse the
// same latitude as a reference whose buffer is followed by NULs, also after
// a line long enough to have filled the whole buffer. Only raw parsed
// values are compared: isValid() and getTimestamp() are the provider's own
// decisions. The date-field over-read is covered by ReadsStayInsideTheGuardTail.
for (bool after_long_line : {false, true}) {
SCOPED_TRACE(after_long_line ? "after a long line" : "first line");
const std::string sentence = overreadAtLatitude();
ASSERT_EQ(99u, sentence.size());
g_mock_millis = 1000;
MockSerial serial;
DirtyProvider dirty(serial);
MicroNMEALocationProvider& gps = *dirty.gps;
ReferenceParse reference;
if (after_long_line) {
feedAndLoop(gps, serial, longJunkLine());
reference.feed(longJunkLine());
}
feedAndLoop(gps, serial, sentence);
reference.feed(sentence);
// The production UTC-field patch rejects this truncated time field. It
// must remain rejected, and buffer layout must not change raw coordinates.
ASSERT_FALSE(reference.nmea.isValid());
EXPECT_EQ(reference.nmea.getLatitude(), gps.getLatitude());
EXPECT_EQ(reference.nmea.getLongitude(), gps.getLongitude());
}
}
TEST(MicroNMEAProvider, CompleteTimeStillKeepsCoordinateLookaheadInsideTheGuard) {
for (bool after_long_line : {false, true}) {
SCOPED_TRACE(after_long_line ? "after a long line" : "first line");
const std::string sentence = overreadAfterCompleteTime();
ASSERT_EQ(99u, sentence.size());
g_mock_millis = 1000;
MockSerial serial;
DirtyProvider dirty(serial);
ReferenceParse reference;
if (after_long_line) {
feedAndLoop(*dirty.gps, serial, longJunkLine());
reference.feed(longJunkLine());
}
feedAndLoop(*dirty.gps, serial, sentence);
reference.feed(sentence);
// This fixture must actually reach the residual coordinate lookahead;
// merely rejecting it in parseTime would not exercise the guard tail.
ASSERT_TRUE(reference.nmea.isValid());
EXPECT_EQ(reference.nmea.getLatitude(), dirty.gps->getLatitude());
EXPECT_EQ(reference.nmea.getLongitude(), dirty.gps->getLongitude());
EXPECT_FALSE(dirty.gps->isValid());
}
}
+372
View File
@@ -0,0 +1,372 @@
#include "gps_test_support.h"
#include <ctime>
#include <vector>
namespace {
struct RecordingClock : mesh::RTCClock {
uint32_t now = 1700000000;
const uint32_t* truth = nullptr;
std::vector<uint32_t> writes; // values written
std::vector<uint32_t> truths; // time carried by the newest RMC at each write
std::vector<uint32_t> write_ms; // mock millis() at each write
uint32_t getCurrentTime() override { return now; }
void setCurrentTime(uint32_t time) override {
now = time;
writes.push_back(time);
truths.push_back(truth ? *truth : 0);
write_ms.push_back(millis());
}
};
// A provider with a clock, a mock UART and mock time. Each send() delivers
// one line and then runs loop() for `ms` of mock time in 100 ms steps.
// `truth` is the UTC time written in the newest RMC sentence delivered.
struct ClockRig {
MockSerial serial;
RecordingClock clock;
MicroNMEALocationProvider gps;
uint32_t truth = 0;
ClockRig() : gps(serial, &clock, -1, -1) {
g_mock_millis = 10000;
clock.truth = &truth;
}
void run(uint32_t ms) {
for (uint32_t t = 0; t < ms; t += 100) {
gps.loop();
// Preserve each synthetic observation time while draining bounded loops.
while (serial.available()) gps.loop();
g_mock_millis += 100;
}
}
void send(const std::string& line, uint32_t carried = 0, uint32_t ms = 1000) {
if (carried) truth = carried;
serial.feed(line + "\r\n");
run(ms);
}
};
// Expected UTC seconds, computed independently of the code under test.
constexpr uint32_t k120034 = 1792238434; // 2026-10-17 12:00:34
constexpr uint32_t k120036 = 1792238436;
constexpr uint32_t k120039 = 1792238439;
constexpr uint32_t k120047 = 1792238447;
constexpr uint32_t k235940 = 1792195180; // 2026-10-16 23:59:40
constexpr uint32_t kMidnight = 1792195200; // 2026-10-17 00:00:00
constexpr uint32_t kLeapDay = 1835438436; // 2028-02-29 12:00:36
// Time and date fields for a UTC second ("hhmmss.00", "ddmmyy"), formatted
// with the C library rather than the code under test.
std::string timeField(uint32_t utc) {
time_t t = utc;
struct tm tm;
gmtime_r(&t, &tm);
char out[16];
snprintf(out, sizeof(out), "%02d%02d%02d.00", tm.tm_hour, tm.tm_min, tm.tm_sec);
return out;
}
std::string dateField(uint32_t utc) {
time_t t = utc;
struct tm tm;
gmtime_r(&t, &tm);
char out[8];
snprintf(out, sizeof(out), "%02d%02d%02d", tm.tm_mday, tm.tm_mon + 1, tm.tm_year % 100);
return out;
}
std::string rmc(const std::string& time, const std::string& date, const char* status = "A") {
return withChecksum("GPRMC," + time + "," + status +
",4716.1234,N,12233.5678,W,0.12,34.5," + date + ",003.1,W,A");
}
std::string rmcAt(uint32_t utc) { return rmc(timeField(utc), dateField(utc)); }
std::string ggaAt(uint32_t utc) {
return withChecksum("GPGGA," + timeField(utc) +
",4716.1234,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,");
}
// RMC with the comma after the E/W field missing. The text still carries
// the right time and date, but MicroNMEA's fixed-width parse lands its date
// on an empty field and keeps the date of an earlier sentence.
std::string misalignedRmcAt(uint32_t utc) {
return withChecksum("GPRMC," + timeField(utc) +
",A,4716.1234,N,12233.5678,W0.12,34.5,," + dateField(utc) + ",003.1,W,A");
}
// RMC whose checksum covers only "GPRMC": the checksum test stops at the
// first '*', so it passes, and MicroNMEA parses none of its fields.
std::string nameOnlyChecksumRmcAt(uint32_t utc) {
return withChecksum("GPRMC") + "," + timeField(utc) +
",A,4716.1234,N,12233.5678,W,0.12,34.5," + dateField(utc) + ",003.1,W,A";
}
constexpr uint32_t kResyncWindowS = 31 * 60; // past the 30-minute resync
} // namespace
TEST(GpsClock, CleanReceiverSetsTheClockOnceFromTheNewestSentence) {
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 8; t++) {
rig.send(ggaAt(t), 0, 0);
rig.send(rmcAt(t), t);
}
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, ReceiverWithoutADateYetNeverSetsTheClock) {
// Position before date: MicroNMEA's date fields are still 0 (upstream
// #3422's case). There is no date in the text, so there is no sample.
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 8; t++) rig.send(rmc(timeField(t), ""));
EXPECT_TRUE(rig.clock.writes.empty());
}
TEST(GpsClock, ChecksumMustCoverTheDate) {
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 8; t++) {
rig.send(withChecksum("GPRMC," + timeField(t) + ",A,4716.1234,N,12233.5678,W,0.12,34.5") +
"," + dateField(t) + ",003.1,W,A");
}
EXPECT_TRUE(rig.clock.writes.empty());
}
TEST(GpsClock, IncompleteOrImpossibleFieldsAreNotSamples) {
// Each defect still parses to a plausible value if its check is missing,
// and the seconds advance, so only the check keeps it from being written.
struct Case {
const char* name;
std::string (*make)(uint32_t);
};
static const Case cases[] = {
{"non-digit in the time", [](uint32_t t) {
return rmc("120:" + timeField(t).substr(4), dateField(t)); }}, // minute "0:" = 10
{"seven-digit time", [](uint32_t t) { return rmc(timeField(t).substr(0, 6) + "5", dateField(t)); }},
{"dot without digits", [](uint32_t t) { return rmc(timeField(t).substr(0, 7), dateField(t)); }},
{"non-digit fraction", [](uint32_t t) { return rmc(timeField(t).substr(0, 8) + "x", dateField(t)); }},
{"hour 24", [](uint32_t t) { return rmc("24" + timeField(t).substr(2), dateField(t)); }},
{"minute 60", [](uint32_t t) { return rmc("1260" + timeField(t).substr(4), dateField(t)); }},
{"non-digit in the date", [](uint32_t t) { return rmc(timeField(t), "1:1026"); }}, // day "1:" = 20
{"five-digit date", [](uint32_t t) { return rmc(timeField(t), "17102"); }},
{"seven-digit date", [](uint32_t t) { return rmc(timeField(t), "1710261"); }},
{"day 0", [](uint32_t t) { return rmc(timeField(t), "001026"); }},
{"day 32", [](uint32_t t) { return rmc(timeField(t), "321026"); }},
{"30 February", [](uint32_t t) { return rmc(timeField(t), "300226"); }},
{"29 February in a common year", [](uint32_t t) { return rmc(timeField(t), "290227"); }},
{"month 0", [](uint32_t t) { return rmc(timeField(t), "170026"); }},
{"month 13", [](uint32_t t) { return rmc(timeField(t), "171326"); }},
};
for (const Case& c : cases) {
SCOPED_TRACE(c.name);
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 8; t++) rig.send(c.make(t));
EXPECT_TRUE(rig.clock.writes.empty());
}
}
TEST(GpsClock, LeapSecondIsNotASample) {
// "60" in the seconds field (a leap second) would otherwise be read as the
// first second of the next minute, while the sentence's own second is :59.
ClockRig rig;
const uint32_t t58 = k120034 + 24, t59 = k120034 + 25; // 12:00:58, 12:00:59
rig.send(rmcAt(t58), t58);
rig.send(rmcAt(t59), t59);
rig.send(rmc("120060.00", dateField(t59)));
for (uint32_t t = t59 + 2; t < t59 + 6; t++) rig.send(rmcAt(t), t);
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, VoidStatusIsNotASample) {
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 8; t++) rig.send(rmc(timeField(t), dateField(t), "V"));
EXPECT_TRUE(rig.clock.writes.empty());
}
TEST(GpsClock, ResyncAfterMidnightUsesTheDateTheSentenceCarries) {
// From midnight on, every sentence is misaligned, so MicroNMEA's date
// stays at 16 Oct. The periodic resync about 30 minutes later must still
// write 17 Oct.
ClockRig rig;
for (uint32_t t = k235940; t < kMidnight + kResyncWindowS; t++) {
rig.send(t < kMidnight ? rmcAt(t) : misalignedRmcAt(t), t);
}
ASSERT_EQ(2u, rig.clock.writes.size());
EXPECT_LT(rig.clock.writes[0], kMidnight);
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, SentencesWithNoParsedFieldsDoNotResyncToAFrozenTime) {
// After the first sync every sentence's checksum covers only "GPRMC", so
// MicroNMEA's time stops at the last parsed sentence. The resync must not
// write that frozen time.
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + kResyncWindowS; t++) {
rig.send(t < k120034 + 10 ? rmcAt(t) : nameOnlyChecksumRmcAt(t), t);
}
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, OneOutlierIsNeverWrittenWhereverItFalls) {
const uint32_t outlier = 4095921636UL; // a 2099 date in a checksum-valid sentence
for (int position = 0; position < 8; position++) {
SCOPED_TRACE(position);
ClockRig rig;
for (int i = 0; i < 10; i++) {
const uint32_t t = k120034 + i;
if (i == position) rig.send(rmc(timeField(t), dateField(outlier)), 0);
else rig.send(rmcAt(t), t);
}
EXPECT_FALSE(rig.clock.writes.empty());
for (uint32_t written : rig.clock.writes) {
EXPECT_LT(written, 2000000000UL) << "no write may carry the outlier's 2099 date";
}
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
}
TEST(GpsClock, SilentReceiverDoesNotReplayAnOldSample) {
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 8; t++) rig.send(rmcAt(t), t);
ASSERT_EQ(1u, rig.clock.writes.size());
// The receiver goes quiet past the 30-minute resync point.
rig.run(kResyncWindowS * 1000UL);
EXPECT_EQ(1u, rig.clock.writes.size());
// One sentence that disagrees with the time elapsed is not enough.
rig.send(rmcAt(k120034 + 8), k120034 + 8);
EXPECT_EQ(1u, rig.clock.writes.size());
}
TEST(GpsClock, ResyncWaitsForAFreshSample) {
// The receiver goes quiet five seconds before the 30-minute resync is due
// and stays quiet for ten. Its last sample is then too old to write.
ClockRig rig;
uint32_t t = k120034;
for (int i = 0; i < 10 && rig.clock.writes.empty(); i++, t++) rig.send(rmcAt(t), t);
ASSERT_EQ(1u, rig.clock.writes.size());
const uint32_t resync_due_ms = rig.clock.write_ms[0] + 1800000UL;
while (g_mock_millis + 5000 < resync_due_ms) rig.send(rmcAt(t), t), t++; // send() advances time
rig.run(10000);
EXPECT_EQ(1u, rig.clock.writes.size());
for (int i = 0; i < 3; i++, t++) rig.send(rmcAt(t + 10), t + 10); // resumes, on time
ASSERT_EQ(2u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, SampleStaysUsableUntilALateCheck) {
// The provider checks the clock about once a second. The third sample
// arrives just before a check is due and the next loop() runs 1.5 s
// later; the sample must still be written then.
ClockRig rig;
auto at = [&rig](uint32_t ms, uint32_t utc) {
g_mock_millis = ms;
if (utc) {
rig.truth = utc;
rig.serial.feed(rmcAt(utc) + "\r\n");
}
rig.gps.loop();
};
at(10000, k120034); // first sample; first check, next due after 11000
at(11100, k120034 + 1); // second sample; check, next due after 12100
at(12050, k120034 + 2); // third sample, check not yet due
EXPECT_TRUE(rig.clock.writes.empty());
at(13550, 0); // late check, sample is 1.5 s old
EXPECT_EQ(std::vector<uint32_t>{k120036}, rig.clock.writes);
}
// The tests below pin the agreement rule: consecutive samples must advance
// with millis(), within two seconds.
TEST(GpsClock, StuckReceiverNeverSetsTheClock) {
ClockRig rig;
for (int i = 0; i < 8; i++) rig.send(rmcAt(k120034), k120034);
EXPECT_TRUE(rig.clock.writes.empty());
}
TEST(GpsClock, TimeJumpRestartsAgreement) {
ClockRig rig;
for (uint32_t t : {k120034, k120034 + 1, k120034 + 11, k120034 + 12, k120034 + 13}) {
rig.send(rmcAt(t), t); // ten seconds ahead after one second of wall time
}
EXPECT_EQ(std::vector<uint32_t>{k120047}, rig.clock.writes);
}
TEST(GpsClock, BackwardStepRestartsAgreement) {
ClockRig rig;
for (uint32_t t : {k120034, k120034 + 1, k120034, k120034 + 1, k120034 + 2}) {
rig.send(rmcAt(t), t);
}
EXPECT_EQ(std::vector<uint32_t>{k120036}, rig.clock.writes);
}
TEST(GpsClock, DeliveryJitterDoesNotBreakAgreement) {
// Sentences arrive 0.9 s and 1.1 s apart, so whole seconds of millis()
// and of GPS time differ by one at every other step.
ClockRig rig;
uint32_t t = k120034;
for (int i = 0; i < 4; i++, t++) {
rig.send(rmcAt(t), t, 900);
++t;
rig.send(rmcAt(t), t, 1100);
}
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, LoopStallOfThreeSecondsDoesNotRestartAgreement) {
// The loop stalls for 2.5 s while the receiver emits two more sentences;
// both are read together, three seconds of millis() after the last one.
ClockRig rig;
rig.send(rmcAt(k120034), k120034);
rig.send(rmcAt(k120034 + 1), k120034 + 1, 500);
g_mock_millis += 2500;
rig.serial.feed(rmcAt(k120034 + 2) + "\r\n" + rmcAt(k120034 + 3) + "\r\n");
rig.truth = k120034 + 3;
rig.run(1000);
EXPECT_EQ(std::vector<uint32_t>{k120034 + 3}, rig.clock.writes);
}
TEST(GpsClock, ForcedSyncNeedsThreeFreshSamples) {
ClockRig rig;
for (uint32_t t = k120034; t <= k120036; t++) rig.send(rmcAt(t), t);
ASSERT_EQ(std::vector<uint32_t>{k120036}, rig.clock.writes);
rig.gps.syncTime();
rig.send(rmcAt(k120036 + 1), k120036 + 1);
rig.send(rmcAt(k120036 + 2), k120036 + 2);
EXPECT_EQ(1u, rig.clock.writes.size());
rig.send(rmcAt(k120039), k120039);
EXPECT_EQ((std::vector<uint32_t>{k120036, k120039}), rig.clock.writes);
}
TEST(GpsClock, ReceiverFasterThanOneHertzStillSetsTheClock) {
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 5; t++) {
for (const char* fraction : {".00", ".20", ".40", ".60", ".80"}) {
std::string time = timeField(t);
time.replace(6, 3, fraction);
rig.send(rmc(time, dateField(t)), t, 200);
}
}
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, LeapDayIsAValidDate) {
ClockRig rig;
for (uint32_t t = kLeapDay - 2; t <= kLeapDay + 2; t++) rig.send(rmcAt(t), t);
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
TEST(GpsClock, TimeFieldWithoutFractionIsAccepted) {
ClockRig rig;
for (uint32_t t = k120034; t < k120034 + 5; t++) {
rig.send(rmc(timeField(t).substr(0, 6), dateField(t)), t);
}
ASSERT_EQ(1u, rig.clock.writes.size());
EXPECT_EQ(rig.clock.truths, rig.clock.writes) << "each write must equal the time in the newest RMC";
}
@@ -0,0 +1,91 @@
#include "gps_test_support.h"
namespace {
// RMC with the given latitude/longitude fields and status, valid checksum.
std::string rmcAt(const char* lat, const char* ns, const char* lon,
const char* ew, const char* status = "A") {
return withChecksum(std::string("GPRMC,120034.00,") + status + "," + lat + "," +
ns + "," + lon + "," + ew + ",0.12,34.5,171026,003.1,W,A");
}
struct Fix {
bool valid;
long lat;
long lon;
};
Fix parseWithProvider(const std::string& sentence) {
MockSerial serial;
MicroNMEALocationProvider gps(serial, nullptr, -1, -1);
feedAndLoop(gps, serial, sentence);
return {gps.isValid(), gps.getLatitude(), gps.getLongitude()};
}
bool libraryCallsItAFix(const std::string& sentence) {
char buffer[100] = {};
MicroNMEA nmea(buffer, sizeof(buffer));
for (char c : sentence) nmea.process(c);
nmea.process('\n');
return nmea.isValid();
}
} // namespace
TEST(MicroNMEAProviderRange, RealFixesUpToTheBoundariesAreValid) {
const struct { const char *lat, *ns, *lon, *ew; long want_lat, want_lon; } cases[] = {
{"4716.1234", "N", "12233.5678", "W", 47268723L, -122559463L},
{"9000.0000", "N", "18000.0000", "E", 90000000L, 180000000L},
{"9000.0000", "S", "18000.0000", "W", -90000000L, -180000000L},
{"0000.0000", "N", "00000.0000", "E", 0L, 0L},
};
for (const auto& c : cases) {
SCOPED_TRACE(std::string(c.lat) + c.ns + " " + c.lon + c.ew);
const Fix fix = parseWithProvider(rmcAt(c.lat, c.ns, c.lon, c.ew));
EXPECT_TRUE(fix.valid);
EXPECT_EQ(c.want_lat, fix.lat);
EXPECT_EQ(c.want_lon, fix.lon);
}
}
TEST(MicroNMEAProviderRange, PositionsNoReceiverCanReportAreNotAFix) {
const struct { const char *lat, *ns, *lon, *ew; } cases[] = {
{"9000.0010", "N", "12233.5678", "W"}, // 90.0000166 N
{"9000.0010", "S", "12233.5678", "W"}, // 90.0000166 S
{"9930.0000", "N", "12233.5678", "W"}, // 99.5 N, inside +-180
{"4716.1234", "N", "18000.0010", "E"}, // 180.0000166 E
{"4716.1234", "N", "18000.0010", "W"}, // 180.0000166 W
{"4716.1234", "N", "18500.0000", "W"}, // 185 W
};
for (const auto& c : cases) {
const std::string sentence = rmcAt(c.lat, c.ns, c.lon, c.ew);
SCOPED_TRACE(sentence);
ASSERT_TRUE(libraryCallsItAFix(sentence));
EXPECT_FALSE(parseWithProvider(sentence).valid);
}
}
TEST(MicroNMEAProviderRange, ChecksumValidSentenceWithAMissingFieldIsNotAFix) {
// The N/S field is missing. The library skips two characters of the
// longitude instead and reads "233.5678" as degrees and minutes: 233 deg.
const std::string sentence = withChecksum(
"GPRMC,120034.00,A,4716.1234,12233.5678,W,0.12,34.5,171026,003.1,W,A");
ASSERT_TRUE(libraryCallsItAFix(sentence));
const Fix fix = parseWithProvider(sentence);
EXPECT_GT(fix.lon < 0 ? -fix.lon : fix.lon, 180000000L);
EXPECT_FALSE(fix.valid);
}
TEST(MicroNMEAProviderRange, NoFixIsStillNoFix) {
EXPECT_FALSE(parseWithProvider(rmcAt("4716.1234", "N", "12233.5678", "W", "V")).valid);
}
TEST(MicroNMEAProviderRange, GgaPositionIsCheckedToo) {
const std::string good = withChecksum(
"GPGGA,120034.00,4716.1234,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,");
const std::string bad = withChecksum(
"GPGGA,120034.00,9930.0000,N,12233.5678,W,1,08,0.9,125.0,M,-17.0,M,,");
EXPECT_TRUE(parseWithProvider(good).valid);
ASSERT_TRUE(libraryCallsItAFix(bad));
EXPECT_FALSE(parseWithProvider(bad).valid);
}
+12
View File
@@ -0,0 +1,12 @@
#include "gps_test_support.h"
// Busy waits in the real provider need advancing monotonic mock time.
void yield() { ++g_mock_millis; }
// Defined in EnvironmentSensorManager.cpp, which is not part of this build.
void LocationProvider::sendSentence(const char*) {}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+42
View File
@@ -0,0 +1,42 @@
#include "gps_test_support.h"
TEST(MicroNMEAProvider, ParsesWellFormedSentences) {
g_mock_millis = 1000;
MockSerial serial;
MicroNMEALocationProvider gps(serial, nullptr, -1, -1);
feedAndLoop(gps, serial, withChecksum(kGoodGga));
feedAndLoop(gps, serial, withChecksum(kGoodRmc));
EXPECT_TRUE(gps.isValid());
EXPECT_EQ(47268723L, gps.getLatitude()); // 47 deg 16.1234' N
EXPECT_EQ(-122559463L, gps.getLongitude()); // 122 deg 33.5678' W
EXPECT_EQ(8L, gps.satellitesCount());
EXPECT_EQ(125000L, gps.getAltitude());
EXPECT_EQ(1792238434L, gps.getTimestamp()); // 2026-10-17 12:00:34 UTC
}
TEST(MicroNMEAProvider, LongestSentenceTheBufferHoldsStillParses) {
std::string body = kGoodRmc;
while (body.size() < 99 - 4) body.push_back('Z'); // pad the final field
const std::string sentence = withChecksum(body);
ASSERT_EQ(99u, sentence.size());
g_mock_millis = 1000;
MockSerial serial;
MicroNMEALocationProvider gps(serial, nullptr, -1, -1);
feedAndLoop(gps, serial, sentence);
EXPECT_TRUE(gps.isValid());
EXPECT_EQ(47268723L, gps.getLatitude());
}
TEST(MicroNMEAProvider, LoopRetainsTheForegroundByteBudget) {
g_mock_millis = 1000;
MockSerial serial;
MicroNMEALocationProvider gps(serial, nullptr, -1, -1);
const size_t length = GPS_LOOP_MAX_BYTES * 2 + 7;
serial.feed(std::string(length, 'X'));
gps.loop();
EXPECT_EQ(length - GPS_LOOP_MAX_BYTES, serial.queue.size());
gps.loop();
EXPECT_EQ(7u, serial.queue.size());
}
+40 -10
View File
@@ -62,28 +62,47 @@ public:
NMEA = r'''
#pragma once
#include <Arduino.h>
#include <ctime>
class MicroNMEA {
bool valid=false;
uint32_t last_ms=now_ms;
uint64_t elapsed_ms=0;
mutable char sentence[100]={};
std::tm utc()const{
const std::time_t epoch=1790942400U+elapsed_ms/1000;
return *std::gmtime(&epoch);
}
public:
inline static unsigned clears=0;
MicroNMEA(char*,size_t){}
void clear(){++clears;valid=false;}
bool process(char c){if(c=='\n'){valid=true;return true;}return false;}
bool process(char c){
if(c!='\n')return false;
elapsed_ms+=static_cast<uint32_t>(now_ms-last_ms);last_ms=now_ms;
valid=true;return true;
}
static bool testChecksum(const char*){return true;}
static void sendSentence(Stream&,const char*){}
const char* getSentence()const{return "$GPRMC,120000.00,A,4807.038,N,01131.000,E,0.0,0.0,021026,,,A*00";}
const char* getSentence()const{
const std::tm value=utc();
std::snprintf(sentence,sizeof(sentence),
"$GPRMC,%02d%02d%02d.00,A,4807.038,N,01131.000,E,0.0,0.0,%02d%02d%02d,,,A*00",
value.tm_hour,value.tm_min,value.tm_sec,value.tm_mday,value.tm_mon+1,
(value.tm_year+1900)%100);
return sentence;
}
const char* getMessageID()const{return "RMC";}
bool isValid()const{return valid;}
long getLatitude()const{return 47610000;}
long getLongitude()const{return -122330000;}
bool getAltitude(long& value)const{value=125000;return valid;}
long getNumSatellites()const{return valid?8:0;}
uint16_t getYear()const{return valid?2026:0;}
uint8_t getMonth()const{return valid?10:0;}
uint8_t getDay()const{return valid?2:0;}
uint8_t getHour()const{return 12;}
uint8_t getMinute()const{return 0;}
uint8_t getSecond()const{return 0;}
uint16_t getYear()const{return valid?utc().tm_year+1900:0;}
uint8_t getMonth()const{return valid?utc().tm_mon+1:0;}
uint8_t getDay()const{return valid?utc().tm_mday:0;}
uint8_t getHour()const{return utc().tm_hour;}
uint8_t getMinute()const{return utc().tm_min;}
uint8_t getSecond()const{return utc().tm_sec;}
};
'''
@@ -91,9 +110,20 @@ RTC = r'''
#pragma once
#include <cstdint>
class DateTime {
uint16_t year;
uint8_t month,day,hour,minute,second;
static bool leap(uint16_t value){return value%4==0&&(value%100!=0||value%400==0);}
public:
DateTime(uint16_t,uint8_t,uint8_t,uint8_t,uint8_t,uint8_t){}
uint32_t unixtime()const{return 1790942400U;}
DateTime(uint16_t y,uint8_t m,uint8_t d,uint8_t h,uint8_t n,uint8_t s)
:year(y),month(m),day(d),hour(h),minute(n),second(s){}
uint32_t unixtime()const{
uint32_t days=0;
for(uint16_t value=1970;value<year;++value)days+=leap(value)?366:365;
const uint8_t lengths[]={31,28,31,30,31,30,31,31,30,31,30,31};
for(uint8_t value=1;value<month;++value)
days+=lengths[value-1]+(value==2&&leap(year)?1:0);
return (days+day-1)*86400U+hour*3600U+minute*60U+second;
}
};
'''