diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..edce11a9668 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_gps -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..44ca4a45f53 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_gps_nmea build_src_filter = -<*> +<../src/Utils.cpp> @@ -191,3 +191,26 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +; GPS/NMEA provider tests compile the real provider header against the real +; MicroNMEA library. The mocks must shadow src/ (same ordering as the KISS +; modem suite). MicroNMEA is an Arduino-format library, so compatibility +; checks are off to let it build for the native platform. Sanitizers make +; out-of-bounds reads in the parser fail the test instead of passing silently. +[env:native_gps] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -fsanitize=address,undefined + -fno-sanitize-recover=all + -fno-omit-frame-pointer +test_build_src = yes +test_filter = test_gps_nmea +build_src_filter = + -<*> +lib_compat_mode = off +lib_deps = + google/googletest @ 1.17.0 + stevemarple/MicroNMEA @ 2.0.6 diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 37ef5762c48..5087bf7eda0 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -48,8 +48,17 @@ class MicroNMEALocationProvider : public LocationProvider { int _pin_reset; int _pin_en; unsigned long next_check = 0; - long time_valid = 0; unsigned long _last_time_sync = 0; + // The clock is set only from the time and date text of an RMC sentence + // that passed its checksum, once consecutive sentences agree with + // millis(). MicroNMEA's own date/time fields can still hold values from + // an earlier sentence when the latest one is malformed. + uint32_t _clock_sample = 0; // UTC seconds from the last accepted RMC + uint32_t _clock_sample_ms = 0; // millis() when that second was first seen + uint8_t _clock_samples = 0; // consecutive samples that agreed + static const uint8_t CLOCK_SAMPLES_REQUIRED = 3; + static const uint32_t CLOCK_SAMPLE_MAX_AGE_MS = 2000; + static const int32_t CLOCK_SAMPLE_TOLERANCE_S = 2; static const unsigned long TIME_SYNC_INTERVAL = 1800000; // Re-sync every 30 minutes #ifdef GPS_L76K @@ -88,6 +97,80 @@ class MicroNMEALocationProvider : public LocationProvider { } } + static bool isDigits(const char* s, int n) { + for (int i = 0; i < n; i++) { + if (s[i] < '0' || s[i] > '9') return false; + } + return true; + } + + static uint8_t twoDigits(const char* s) { return (s[0] - '0') * 10 + (s[1] - '0'); } + + static uint8_t daysInMonth(uint16_t year, uint8_t month) { + static const uint8_t days[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + bool leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + return month == 2 && leap ? 29 : days[month - 1]; + } + + // UTC seconds from the time and date fields of an RMC sentence, or 0 when + // the sentence does not carry a complete time, an 'A' (valid) status and a + // complete date. The checksum covers only what comes before the first + // '*', and the date is the last field used, so it must end before it. + static uint32_t rmcEpoch(const char* sentence) { + const char* star = strchr(sentence, '*'); + const char* time = strchr(sentence, ','); + if (star == NULL || time == NULL) return 0; + time++; + const char* time_end = strchr(time, ','); + if (time_end == NULL || !isDigits(time, 6)) return 0; // hhmmss + if (time_end - time > 6 // optional .s+ + && (time[6] != '.' || time_end - time == 7 || !isDigits(time + 7, time_end - time - 7))) return 0; + const char* status = time_end + 1; + if (status[0] != 'A' || status[1] != ',') return 0; + const char* date = status + 2; + for (int field = 3; field <= 8; field++) { // latitude .. course + date = strchr(date, ','); + if (date == NULL) return 0; + date++; + } + if (date + 6 > star || !isDigits(date, 6) || (date[6] != ',' && date[6] != '*')) return 0; // ddmmyy + + const uint8_t hour = twoDigits(time), minute = twoDigits(time + 2), second = twoDigits(time + 4); + const uint8_t day = twoDigits(date), month = twoDigits(date + 2); + const uint16_t year = 2000 + twoDigits(date + 4); + if (hour > 23 || minute > 59 || second > 59 || month < 1 || month > 12 + || day < 1 || day > daysInMonth(year, month)) return 0; + return DateTime(year, month, day, hour, minute, second).unixtime(); + } + + // Count consecutive RMC samples whose seconds advance with millis(). A + // sentence without a usable time, or a repeat of the same second + // (receivers faster than 1 Hz), is ignored. A sample that disagrees by + // more than the tolerance (loop stalls, lost sentences) starts a new run. + void noteClockSample(uint32_t epoch, uint32_t now) { + if (epoch == 0) return; + if (_clock_samples > 0) { + const int32_t gps_s = (int32_t)(epoch - _clock_sample); + const int32_t wall_s = (int32_t)((now - _clock_sample_ms) / 1000); + const int32_t drift = gps_s - wall_s; + if (gps_s >= 0 && drift >= -CLOCK_SAMPLE_TOLERANCE_S && drift <= CLOCK_SAMPLE_TOLERANCE_S) { + if (gps_s == 0) return; + if (_clock_samples < CLOCK_SAMPLES_REQUIRED) _clock_samples++; + _clock_sample = epoch; + _clock_sample_ms = now; + return; + } + } + _clock_samples = 1; + _clock_sample = epoch; + _clock_sample_ms = now; + } + + bool clockSampleReady() const { + return _clock_samples >= CLOCK_SAMPLES_REQUIRED + && (uint32_t)(millis() - _clock_sample_ms) <= CLOCK_SAMPLE_MAX_AGE_MS; + } + 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) { @@ -157,7 +240,11 @@ public : } } - void syncTime() override { nmea.clear(); LocationProvider::syncTime(); } + void syncTime() override { + nmea.clear(); + _clock_samples = 0; + LocationProvider::syncTime(); + } long getLatitude() override { return nmea.getLatitude(); } long getLongitude() override { return nmea.getLongitude(); } long getAltitude() override { @@ -228,30 +315,28 @@ public : _last_fix_ms = millis(); _fix_seen = true; } + if (strcmp(message_id, "RMC") == 0) { + noteClockSample(rmcEpoch(nmea.getSentence()), millis()); + } } else { _nmea_checksum_bad++; } } } - if (!isValid()) time_valid = 0; - if ((long)(millis() - next_check) > 0) { next_check = millis() + 1000; // Re-enable time sync periodically when GPS has valid fix if (!_time_sync_needed && _clock != NULL && (millis() - _last_time_sync) > TIME_SYNC_INTERVAL) { _time_sync_needed = true; } - if (_time_sync_needed && time_valid > 2) { + if (_time_sync_needed && clockSampleReady()) { if (_clock != NULL) { - _clock->setCurrentTime(getTimestamp()); + _clock->setCurrentTime(_clock_sample); _time_sync_needed = false; _last_time_sync = millis(); } } - if (isValid()) { - time_valid ++; - } } } diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..bcd6620c4f4 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -2,6 +2,7 @@ #include #include +#include // Arduino.h provides the C character classification functions #include "Stream.h" inline uint32_t g_mock_millis = 0; @@ -15,3 +16,26 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +// Busy-wait loops call yield(); let mock time move so they can time out. +inline void yield() { + g_mock_millis += 1; +} + +// Minimal digital I/O so headers that drive enable/reset pins compile natively. +constexpr uint8_t LOW = 0; +constexpr uint8_t HIGH = 1; +constexpr uint8_t INPUT = 0; +constexpr uint8_t OUTPUT = 1; + +inline uint8_t g_mock_pin_levels[64] = {}; + +inline void pinMode(uint8_t, uint8_t) {} + +inline void digitalWrite(uint8_t pin, uint8_t level) { + if (pin < 64) g_mock_pin_levels[pin] = level; +} + +inline int digitalRead(uint8_t pin) { + return pin < 64 ? g_mock_pin_levels[pin] : LOW; +} diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h index b6c263c199a..6f73b0b0bc4 100644 --- a/test/mocks/Mesh.h +++ b/test/mocks/Mesh.h @@ -15,6 +15,13 @@ class Radio { 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; diff --git a/test/mocks/RTClib.h b/test/mocks/RTClib.h new file mode 100644 index 00000000000..6a02326a04b --- /dev/null +++ b/test/mocks/RTClib.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +// 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; + } +}; diff --git a/test/test_gps_nmea/gps_test_support.h b/test/test_gps_nmea/gps_test_support.h new file mode 100644 index 00000000000..0a7b733ef4c --- /dev/null +++ b/test/test_gps_nmea/gps_test_support.h @@ -0,0 +1,49 @@ +#pragma once + +// Shared helpers for the native GPS/NMEA tests: the real +// MicroNMEALocationProvider and MicroNMEA, fed through a mock UART. + +#include + +#include +#include + +#include +#include +#include +#include + + +struct MockSerial : Stream { + std::deque 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"); + 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,,"; diff --git a/test/test_gps_nmea/test_clock.cpp b/test/test_gps_nmea/test_clock.cpp new file mode 100644 index 00000000000..f895bc03ae2 --- /dev/null +++ b/test/test_gps_nmea/test_clock.cpp @@ -0,0 +1,369 @@ +#include "gps_test_support.h" + +#include +#include + +namespace { + +struct RecordingClock : mesh::RTCClock { + uint32_t now = 1700000000; + const uint32_t* truth = nullptr; + std::vector writes; // values written + std::vector truths; // time carried by the newest RMC at each write + std::vector 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(); + 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{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{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{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); + 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{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{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{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"; +} diff --git a/test/test_gps_nmea/test_main.cpp b/test/test_gps_nmea/test_main.cpp new file mode 100644 index 00000000000..c26f6efeba8 --- /dev/null +++ b/test/test_gps_nmea/test_main.cpp @@ -0,0 +1,9 @@ +#include "gps_test_support.h" + +// 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(); +} diff --git a/test/test_gps_nmea/test_parsing.cpp b/test/test_gps_nmea/test_parsing.cpp new file mode 100644 index 00000000000..4755ba77e0c --- /dev/null +++ b/test/test_gps_nmea/test_parsing.cpp @@ -0,0 +1,30 @@ +#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()); +}