mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-25 01:32:36 +00:00
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:
1 parent
55f67f9062
commit
3db4582bbb
14 files changed
+1054
-52
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,,";
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
'''
|
||||
|
||||
|
||||
Reference in new issue
Block a user