diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 2ad3b38e8..78ca72b2b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/platformio.ini b/platformio.ini index 5222e3767..ef68dfd63 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index e0fb0af1d..ca2afee5f 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -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(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(now - static_cast(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(millis() - _last_time_sync) >= periodicTimeSyncIntervalMillis()) { @@ -360,17 +407,17 @@ public : } const bool fresh_clock = _fresh_clock_sample && static_cast(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(); diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index fd10d8c97..c46379864 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -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); diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h index 89e91800d..838a72b3d 100644 --- a/test/mocks/Mesh.h +++ b/test/mocks/Mesh.h @@ -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; diff --git a/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp b/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp index af12a9994..8af653aa2 100644 --- a/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp +++ b/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp @@ -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")); diff --git a/test/test_gps_nmea/RTClib.h b/test/test_gps_nmea/RTClib.h new file mode 100644 index 000000000..6a02326a0 --- /dev/null +++ b/test/test_gps_nmea/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 000000000..b19914683 --- /dev/null +++ b/test/test_gps_nmea/gps_test_support.h @@ -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 + +#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"); + // 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,,"; diff --git a/test/test_gps_nmea/test_buffer_guard.cpp b/test/test_gps_nmea/test_buffer_guard.cpp new file mode 100644 index 000000000..6e1830af8 --- /dev/null +++ b/test/test_gps_nmea/test_buffer_guard.cpp @@ -0,0 +1,201 @@ +#include "gps_test_support.h" + +#include +#include +#include + +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 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 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()); + } +} diff --git a/test/test_gps_nmea/test_clock.cpp b/test/test_gps_nmea/test_clock.cpp new file mode 100644 index 000000000..2529d7d24 --- /dev/null +++ b/test/test_gps_nmea/test_clock.cpp @@ -0,0 +1,372 @@ +#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(); + // 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{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); + ++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{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_coordinate_range.cpp b/test/test_gps_nmea/test_coordinate_range.cpp new file mode 100644 index 000000000..cb3088b86 --- /dev/null +++ b/test/test_gps_nmea/test_coordinate_range.cpp @@ -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); +} diff --git a/test/test_gps_nmea/test_main.cpp b/test/test_gps_nmea/test_main.cpp new file mode 100644 index 000000000..949dfd06b --- /dev/null +++ b/test/test_gps_nmea/test_main.cpp @@ -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(); +} diff --git a/test/test_gps_nmea/test_parsing.cpp b/test/test_gps_nmea/test_parsing.cpp new file mode 100644 index 000000000..ad8823ac7 --- /dev/null +++ b/test/test_gps_nmea/test_parsing.cpp @@ -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()); +} diff --git a/test/test_gps_upstream_adaptations.py b/test/test_gps_upstream_adaptations.py index c05962df9..21d9aa336 100644 --- a/test/test_gps_upstream_adaptations.py +++ b/test/test_gps_upstream_adaptations.py @@ -62,28 +62,47 @@ public: NMEA = r''' #pragma once #include +#include 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(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 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