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>
373 lines
15 KiB
C++
373 lines
15 KiB
C++
#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";
|
|
}
|