From 10aaa210a1761451e5febc1e086758b331a1d9b7 Mon Sep 17 00:00:00 2001 From: nullrouten0 <201262769+nullrouten0@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:33:36 +0000 Subject: [PATCH 1/2] test: add a native GPS/NMEA test environment Add a native_gps PlatformIO environment that compiles the real MicroNMEALocationProvider header against the real MicroNMEA 2.0.6 library, with the address and undefined-behaviour sanitizers on, and run it in the unit-test workflow. The mocks gain what the provider header needs to compile on a host: yield(), the C character classification functions, minimal digital I/O, mesh::RTCClock and a DateTime stand-in. The suite has a shared helper header and starts with a parsing smoke test on well-formed GGA and RMC sentences; later changes add their own test files to it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 25 +++++++++++++- test/mocks/Arduino.h | 24 +++++++++++++ test/mocks/Mesh.h | 7 ++++ test/mocks/RTClib.h | 39 +++++++++++++++++++++ test/test_gps_nmea/gps_test_support.h | 49 +++++++++++++++++++++++++++ test/test_gps_nmea/test_main.cpp | 9 +++++ test/test_gps_nmea/test_parsing.cpp | 30 ++++++++++++++++ 8 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 test/mocks/RTClib.h create mode 100644 test/test_gps_nmea/gps_test_support.h create mode 100644 test/test_gps_nmea/test_main.cpp create mode 100644 test/test_gps_nmea/test_parsing.cpp 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/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_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()); +} From 72ed4dcf08869c6900ef579e7af1b28471a86895 Mon Sep 17 00:00:00 2001 From: nullrouten0 <201262769+nullrouten0@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:29:29 +0000 Subject: [PATCH 2/2] GPS: set the clock only from the RMC sentence that was validated MicroNMEALocationProvider counted seconds while the position flag was true and then wrote getTimestamp(), which reads MicroNMEA's current fields. Nothing tied those fields to the sentence that had just passed its checksum: before any date is parsed the date fields are zero; a missing or merged field makes parseDate() keep the previous date; and a sentence whose checksum covers only "GPRMC" parses nothing at all. On dev the new tests show it writing a 2099 date from one bad sentence, the previous day at the 30-minute resync after midnight, and a 30-minute-old time when sentences stop parsing. Read the clock sample from the text of each RMC that passed its checksum: hhmmss, status 'A' and ddmmyy, all before the first '*', and a real calendar date. Set the RTC only after three consecutive samples advance with millis() within 2 s, and only if the newest is at most 2 s old. Repeats of the same second are ignored so faster receivers work. When syncs happen, the position path and getTimestamp() are unchanged. Receivers that send hhmmss without fractional seconds now set the clock (MicroNMEA misreads their position, so they never did); receivers that send GGA but no RMC no longer do (GGA has no date). Co-Authored-By: Claude Opus 5.5 --- .../sensors/MicroNMEALocationProvider.h | 103 ++++- test/test_gps_nmea/test_clock.cpp | 369 ++++++++++++++++++ 2 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 test/test_gps_nmea/test_clock.cpp 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/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"; +}