diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 87ac9a301..572f6c853 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -577,6 +577,7 @@ jobs: python3 -B test/test_gps_upstream_adaptations.py -v python3 -B test/test_esp32_gps_discovery.py -v python3 -B test/test_gps_sync_interval.py -v + python3 -B test/test_rak_gps_clock_freshness.py -v python3 -B test/test_gps_sync_preferences.py -v python3 -B test/test_rak_gps_power.py -v python3 -B test/test_rak_shutdown_power.py -v diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index b52f2f644..920d3100a 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -429,7 +429,6 @@ class RAK12500LocationProvider : public LocationProvider { int _sats = 0; long _epoch = 0; bool _fix = false; - bool _fresh_position_requested = false; mesh::RTCClock* _clock = NULL; unsigned long _next_time_check = 0; unsigned long _last_time_sync = 0; @@ -449,7 +448,6 @@ public: bool isValid() override { return _fix; } bool clearPositionFix() override { _fix = false; - _fresh_position_requested = true; _next_time_check = 0; ublox_GNSS.flushPVT(); return true; @@ -466,7 +464,6 @@ public: _sats = 0; _epoch = 0; _fix = false; - _fresh_position_requested = false; _fresh_time_sync_required = false; _next_time_check = 0; _last_time_sync = 0; @@ -483,17 +480,13 @@ public: if ((int32_t)(now - _next_time_check) >= 0) { _next_time_check = now + 1000; - if (_fresh_position_requested || _fresh_time_sync_required) { - // The library's scalar getters can return retained data even when - // their implicit PVT poll fails. Require a successfully received PVT - // before exposing a position for this tracker acquisition. - ublox_GNSS.flushPVT(); - if (!ublox_GNSS.getPVT(8)) { - _fix = false; - _valid_time_samples = 0; - return; - } - _fresh_position_requested = false; + // Scalar getters return retained data even if their implicit poll fails. + // Require a received PVT in normal operation as well as tracker windows. + ublox_GNSS.flushPVT(); + if (!ublox_GNSS.getPVT(8)) { + _fix = false; + _valid_time_samples = 0; + return; } if (ublox_GNSS.getGnssFixOk(8)) { _fix = true; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index c99e9fdd5..e0fb0af1d 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -51,10 +51,10 @@ class MicroNMEALocationProvider : public LocationProvider { RefCountedDigitalPin* _peripher_power; int8_t _claims = 0; bool _started = false; + uint8_t time_valid = 0; int _pin_reset; int _pin_en; unsigned long next_check = 0; - long time_valid = 0; unsigned long _last_time_sync = 0; #ifdef GPS_L76K @@ -81,6 +81,43 @@ class MicroNMEALocationProvider : public LocationProvider { bool _nmea_seen = false; bool _fix_seen = false; bool _fresh_clock_sample = false; + uint32_t _clock_sample_epoch = 0; + uint32_t _clock_sample_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. + const char* time = strchr(sentence, ','); + if (time == NULL) return false; + ++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 - 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; + } + } + + // 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; + ++date; + } + for (unsigned i = 0; i < 6; ++i) { + if (date[i] < '0' || date[i] > '9') return false; + } + return date[6] == ',' || date[6] == '*'; + } static uint32_t ageMs(unsigned long timestamp) { return (uint32_t)(millis() - timestamp); @@ -177,7 +214,12 @@ public : return _pin_en; } - void syncTime() override { nmea.clear(); LocationProvider::syncTime(); } + void syncTime() override { + nmea.clear(); + time_valid = 0; + _fresh_clock_sample = false; + LocationProvider::syncTime(); + } void beginFreshTimeSync() override { time_valid = 0; _fresh_clock_sample = false; @@ -196,6 +238,8 @@ public : bool isValid() override { return nmea.isValid(); } bool clearPositionFix() override { nmea.clear(); + time_valid = 0; + _fresh_clock_sample = false; // Discard only bytes already queued before the acquisition begins. // Snapshot the count so a noisy live UART cannot make this unbounded. int buffered = _gps_serial->available(); @@ -281,7 +325,21 @@ public : (strcmp(message_id, "GGA") == 0 || strcmp(message_id, "RMC") == 0)) { _last_fix_ms = millis(); _fix_seen = true; - _fresh_clock_sample = true; + } + // GGA supplies only time-of-day. Never combine it with a + // 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; + _fresh_clock_sample = epoch > 0; + if (_fresh_clock_sample) { + _clock_sample_epoch = static_cast(epoch); + _clock_sample_at = millis(); + } else { + time_valid = 0; + } } } else { _nmea_checksum_bad++; @@ -291,25 +349,30 @@ public : if (!isValid()) time_valid = 0; - if ((long)(millis() - next_check) > 0) { - next_check = millis() + 1000; + 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 if (!_time_sync_needed && _clock != NULL && static_cast(millis() - _last_time_sync) >= periodicTimeSyncIntervalMillis()) { _time_sync_needed = true; } - const long timestamp = getTimestamp(); - if (isValid() && satellitesCount() >= 5 && timestamp > 0 - && (!_fresh_time_sync_required || _fresh_clock_sample)) { - time_valid++; - } else { + 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) { + // 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 > 2) { + if (_time_sync_needed && time_valid >= 3 && fresh_clock) { if (_clock != NULL) { - _clock->setCurrentTime(timestamp); + _clock->setCurrentTime(_clock_sample_epoch); markTimeSyncApplied(); _time_sync_needed = false; _last_time_sync = millis(); diff --git a/test/fixtures/rak_gps_clock_freshness.cpp b/test/fixtures/rak_gps_clock_freshness.cpp new file mode 100644 index 000000000..eb1881d3a --- /dev/null +++ b/test/fixtures/rak_gps_clock_freshness.cpp @@ -0,0 +1,241 @@ +// Compile the actual provider/base policy. Only GNSS, clock and Arduino I/O +// are adapters. This is not a full EnvironmentSensorManager or I2C test. +#include +#include +#include +#include +#include +#include + +void LocationProvider::sendSentence(const char*) {} + +struct Pvt { + bool fix = true, date = true, time = true; + int sats = 8; + long lat = 476100000, lng = -1223300000, alt = 125000, epoch = 1792195200; +}; + +// SparkFun 2.2.x flushPVT clears unread bits, not the PVT payload. Each scalar +// getter implicitly polls if its bit is clear, ignores failure, then returns +// the retained payload. Only the eight fields consumed by this provider are +// modeled (UnixEpoch's calendar conversion/parser are outside this boundary). +struct Gnss { + enum Field { FIX, LAT, LNG, ALT, SATS, DATE, TIME, EPOCH, FIELDS }; + Pvt incoming, cached{false, false, false, 0, 0, 0, 0, 0}; + bool available = true; + uint8_t unread = 0; + unsigned polls = 0, received = 0, flushes = 0; + std::array reads{}; + std::vector waits; + void flushPVT() { ++flushes; unread = 0; } + bool getPVT(uint16_t max_wait) { + ++polls; + waits.push_back(max_wait); + if (!available) return false; + ++received; + cached = incoming; + unread = 0xff; + return true; + } + void access(Field field, uint16_t max_wait) { + ++reads[field]; + const uint8_t mask = static_cast(1U << field); + if (!(unread & mask)) (void)getPVT(max_wait); + unread &= static_cast(~mask); + } + bool getGnssFixOk(uint16_t wait) { access(FIX, wait); return cached.fix; } + long getLatitude(uint16_t wait) { access(LAT, wait); return cached.lat; } + long getLongitude(uint16_t wait) { access(LNG, wait); return cached.lng; } + long getAltitude(uint16_t wait) { access(ALT, wait); return cached.alt; } + int getSIV(uint16_t wait) { access(SATS, wait); return cached.sats; } + bool getDateValid(uint16_t wait) { access(DATE, wait); return cached.date; } + bool getTimeValid(uint16_t wait) { access(TIME, wait); return cached.time; } + long getUnixEpoch(uint16_t wait) { access(EPOCH, wait); return cached.epoch; } +} ublox_GNSS; + +@PROVIDER@ + +static const char* scenario = ""; +static void check(bool value, const char* diagnostic, int line) { + if (value) return; + std::fprintf(stderr, "FAIL %s: %s (line %d)\n", scenario, diagnostic, line); + std::exit(1); +} +#define CHECK(value, diagnostic) check((value), (diagnostic), __LINE__) + +// A due loop must perform exactly one bounded PVT poll. Successful cache +// getters must not cause hidden additional polls; failed polls read no fields. +static void poll(RAK12500LocationProvider& gps, uint32_t at) { + const auto polls = ublox_GNSS.polls, flushes = ublox_GNSS.flushes; + const auto reads = ublox_GNSS.reads; + now_ms = at; + gps.loop(); + CHECK(ublox_GNSS.polls == polls + 1 && ublox_GNSS.waits.back() == 8, + "due loop must use exactly one eight-millisecond PVT poll"); + CHECK(ublox_GNSS.flushes == flushes + 1, "due loop must discard unread status before polling"); + for (unsigned field = 0; field < Gnss::FIELDS; ++field) { + const bool position = field >= Gnss::LAT && field <= Gnss::SATS; + const unsigned count = ublox_GNSS.available && (!position || ublox_GNSS.incoming.fix); + CHECK(ublox_GNSS.reads[field] == reads[field] + count, + "PVT cache getter access budget changed"); + } +} + +static void good(RAK12500LocationProvider& gps, uint32_t at) { + ublox_GNSS.available = true; + ublox_GNSS.incoming.epoch = 1792195200 + at / 1000; + poll(gps, at); +} + +static void qualifiedThree(RAK12500LocationProvider& gps, uint32_t first) { + for (unsigned i = 0; i < 3; ++i) good(gps, first + i * 1000U); +} + +int main(int argc, char** argv) { + if (argc != 2) return 2; + scenario = argv[1]; + mesh::RTCClock clock; + RAK12500LocationProvider gps; + gps.setRTCClock(&clock); + if (!std::strcmp(scenario, "startup-silence")) { + // Put the clock oracle before budget/fix assertions so the old normal + // path fails for counting retained timestamps, not an incidental metric. + now_ms = 1; gps.loop(); + CHECK(gps.isValid() && !clock.writes && ublox_GNSS.received == 1, + "one new startup PVT must update position without setting clock"); + ublox_GNSS.available = false; + now_ms = 1001; gps.loop(); now_ms = 2001; gps.loop(); + CHECK(clock.writes == 0, "stale PVT must not update the clock"); + CHECK(!gps.isValid() && gps.waitingTimeSync() && !gps.getTimeSyncGeneration(), + "failed startup polls must invalidate fix without claiming time sync"); + CHECK(ublox_GNSS.received == 1 && ublox_GNSS.polls == 3 + && ublox_GNSS.flushes == 3, "startup failure poll budget changed"); + qualifiedThree(gps, 3001); + CHECK(clock.writes == 1 && gps.getTimeSyncGeneration() == 1, + "three new PVTs must recover startup time acquisition"); + } else if (!std::strcmp(scenario, "periodic-silence")) { + for (unsigned i = 0; i < 3; ++i) { + now_ms = 1 + i * 1000; ublox_GNSS.incoming.epoch += i; gps.loop(); + } + CHECK(clock.writes == 1 && !gps.waitingTimeSync(), "initial three-PVT sync failed"); + CHECK(gps.consumeTimeSyncApplied() && !gps.consumeTimeSyncApplied(), + "applied notification must be an edge"); + const auto synced = clock.getCurrentTime(); + const uint32_t due = now_ms + gps.periodicTimeSyncIntervalMillis(); + ublox_GNSS.available = false; + now_ms = due; gps.loop(); + CHECK(clock.writes == 1, "stale PVT must not update the clock"); + CHECK(clock.getCurrentTime() == synced + 1800 && gps.getTimeSyncGeneration() == 1 + && !gps.consumeTimeSyncApplied(), "failed periodic poll must not rewind RTC or emit sync"); + CHECK(!gps.isValid() && ublox_GNSS.received == 3 && ublox_GNSS.polls == 4, + "failed periodic acquisition must retain only old cache, not a fix"); + good(gps, due + 1000); good(gps, due + 2000); + CHECK(clock.writes == 1, "periodic recovery must require three new PVTs after failure"); + good(gps, due + 3000); + CHECK(clock.writes == 2 && gps.getTimeSyncGeneration() == 2, + "fresh periodic acquisition must eventually update RTC"); + CHECK(clock.getCurrentTime() == static_cast(gps.getTimestamp()), + "periodic recovery wrote a timestamp other than the new PVT"); + } else if (!std::strcmp(scenario, "consecutive-failure")) { + good(gps, 1); good(gps, 1001); + CHECK(!clock.writes, "two samples must not set the clock"); + ublox_GNSS.available = false; poll(gps, 2001); + good(gps, 3001); good(gps, 4001); + CHECK(!clock.writes, "poll failure must reset consecutive fresh-sample count"); + good(gps, 5001); + CHECK(clock.writes == 1 && gps.isValid(), "third post-failure fresh PVT must restore sync"); + } else if (!std::strcmp(scenario, "poll-budget")) { + good(gps, 1); + const auto reads = ublox_GNSS.reads; + for (uint32_t at : {1U, 2U, 500U, 1000U}) { now_ms = at; gps.loop(); } + CHECK(ublox_GNSS.polls == 1 && ublox_GNSS.flushes == 1 && ublox_GNSS.reads == reads, + "subsecond service must not poll or reread receiver cache"); + good(gps, 1001); + const long lat = gps.getLatitude(), lng = gps.getLongitude(), alt = gps.getAltitude(); + const long sats = gps.satellitesCount(), epoch = gps.getTimestamp(); + ublox_GNSS.available = false; poll(gps, 2001); + CHECK(!gps.isValid() && !clock.writes, "failed poll must not count retained navigation data"); + const auto polls = ublox_GNSS.polls; + CHECK(gps.getLatitude() == lat && gps.getLongitude() == lng && gps.getAltitude() == alt + && gps.satellitesCount() == sats && gps.getTimestamp() == epoch, + "failure must preserve scalar cache for explicitly invalid position"); + CHECK(ublox_GNSS.polls == polls, "provider cache getters must not perform I2C polls"); + } else if (!std::strcmp(scenario, "position-request")) { + good(gps, 1); + CHECK(gps.clearPositionFix() && !gps.isValid(), "position request must invalidate old fix"); + ublox_GNSS.available = false; poll(gps, 2); + CHECK(!gps.isValid() && !clock.writes, "position-only request cannot reuse retained fix"); + qualifiedThree(gps, 1002); + CHECK(gps.isValid() && clock.writes == 1, "position-only request must recover with fresh PVTs"); + } else if (!std::strcmp(scenario, "fresh-tracker")) { + gps.beginFreshTimeSync(); + ublox_GNSS.available = false; poll(gps, 1); poll(gps, 1001); + CHECK(!gps.isValid() && !clock.writes && gps.waitingTimeSync(), + "fresh tracker request cannot use failed polls"); + good(gps, 2001); good(gps, 3001); + CHECK(!clock.writes, "tracker clock must wait for three fresh samples"); + good(gps, 4001); + CHECK(clock.writes == 1 && gps.getTimeSyncGeneration() == 1 && gps.consumeTimeSyncApplied(), + "fresh tracker request must finish with one applied edge"); + gps.beginFreshTimeSync(); + ublox_GNSS.available = false; poll(gps, 5001); + CHECK(!gps.isValid() && clock.writes == 1 && gps.getTimeSyncGeneration() == 1, + "a second tracker request must not inherit its previous valid cache"); + qualifiedThree(gps, 6001); + CHECK(clock.writes == 2 && gps.getTimeSyncGeneration() == 2 + && gps.consumeTimeSyncApplied() && !gps.consumeTimeSyncApplied(), + "second tracker request must require three new samples and emit one edge"); + gps.endFreshTimeSync(); + } else if (!std::strcmp(scenario, "reset")) { + qualifiedThree(gps, 1); + CHECK(clock.writes == 1, "reset control needs a previous applied fix"); + gps.beginFreshTimeSync(); gps.clearPositionFix(); gps.reset(); + CHECK(!gps.isValid() && !gps.getLatitude() && !gps.getLongitude() && !gps.getAltitude() + && !gps.satellitesCount() && !gps.getTimestamp() && !gps.getLastValidTimeSync() + && gps.waitingTimeSync() && !gps.consumeTimeSyncApplied(), + "reset must clear public fix/time state before reselecting the receiver"); + ublox_GNSS.available = false; poll(gps, 2002); + CHECK(clock.writes == 1 && !gps.isValid() && !gps.getTimestamp(), + "reset provider must not republish retained receiver cache after failed poll"); + good(gps, 3002); good(gps, 4002); + CHECK(clock.writes == 1, "reset must discard the previous consecutive sample count"); + good(gps, 5002); + CHECK(clock.writes == 2 && gps.getTimeSyncGeneration() == 2, + "reset must allow a newly qualified sync without reusing an applied edge"); + } else if (!std::strcmp(scenario, "null-clock")) { + gps.setRTCClock(nullptr); + qualifiedThree(gps, 1); + CHECK(gps.isValid() && gps.getTimestamp() > 0 && !clock.writes + && gps.waitingTimeSync() && !gps.getTimeSyncGeneration() && !gps.consumeTimeSyncApplied(), + "clockless location acquisition must not fabricate a time-sync result"); + ublox_GNSS.available = false; poll(gps, 3001); + CHECK(!gps.isValid(), "clockless failed poll must also invalidate fix"); + } else if (!std::strncmp(scenario, "invalid-", 8)) { + good(gps, 1); good(gps, 1001); + const char* field = scenario + 8; + if (!std::strcmp(field, "fix")) ublox_GNSS.incoming.fix = false; + else if (!std::strcmp(field, "date")) ublox_GNSS.incoming.date = false; + else if (!std::strcmp(field, "time")) ublox_GNSS.incoming.time = false; + else if (!std::strcmp(field, "sats")) ublox_GNSS.incoming.sats = 4; + else if (!std::strcmp(field, "epoch")) ublox_GNSS.incoming.epoch = 0; + else return 2; + poll(gps, 2001); + CHECK(!clock.writes, "unqualified fresh PVT must not finish time acquisition"); + if (!std::strcmp(field, "fix")) CHECK(!gps.isValid(), "no-nav PVT must invalidate position"); + ublox_GNSS.incoming = Pvt{}; + good(gps, 3001); good(gps, 4001); + CHECK(!clock.writes, "unqualified PVT must reset consecutive time samples"); + good(gps, 5001); + CHECK(clock.writes == 1 && gps.isValid(), "three newly qualified PVTs must restore time sync"); + } else if (!std::strcmp(scenario, "boundary-contract")) { + CHECK(ublox_GNSS.getPVT(8), "boundary needs a received PVT"); + const auto lat = ublox_GNSS.cached.lat, epoch = ublox_GNSS.cached.epoch; + ublox_GNSS.flushPVT(); ublox_GNSS.available = false; + CHECK(!ublox_GNSS.getPVT(8) && ublox_GNSS.cached.lat == lat, + "failed explicit poll must retain receiver payload"); + CHECK(ublox_GNSS.getLatitude(2) == lat && ublox_GNSS.getUnixEpoch(2) == epoch + && ublox_GNSS.polls == 4 && ublox_GNSS.received == 1, + "failed implicit getters must still return retained payload"); + } else return 2; + std::printf("PASS %s\n", scenario); +} diff --git a/test/test_gps_clock_freshness/RTClib.h b/test/test_gps_clock_freshness/RTClib.h new file mode 100644 index 000000000..a2ced2c02 --- /dev/null +++ b/test/test_gps_clock_freshness/RTClib.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +// Scoped host adapter: calculate UTC epoch seconds from the complete date. +// Unlike the command-only GPS fixture, this must not return a constant zero. +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; + 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; + return days * 86400UL + _hour * 3600UL + _minute * 60UL + _second; + } +}; diff --git a/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp b/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp new file mode 100644 index 000000000..c2f11a66b --- /dev/null +++ b/test/test_gps_clock_freshness/test_gps_clock_freshness.cpp @@ -0,0 +1,503 @@ +#include +#include +#include +#include +#include +#include + +void yield() { ++g_mock_millis; } + +// This includes the actual provider, LocationProvider and pinned native parser. +#include + +void LocationProvider::sendSentence(const char*) {} + +namespace { + +constexpr uint32_t NOON_2026_10_17 = 1792238400UL; +constexpr uint32_t MIDNIGHT_2026_10_18 = 1792281600UL; + +class GpsInput : public Stream { +public: + std::deque incoming; + + void receive(const std::string& text) { + for (unsigned char c : text) incoming.push_back(c); + } + int available() override { return static_cast(incoming.size()); } + int read() override { + if (incoming.empty()) return -1; + const uint8_t c = incoming.front(); + incoming.pop_front(); + return c; + } +}; + +class AdvancingRtc : public mesh::RTCClock { + uint32_t _epoch = NOON_2026_10_17 - 400; + uint32_t _at = 0; + +public: + struct Write { + uint32_t at; + uint32_t before; + uint32_t after; + }; + std::vector writes; + + uint32_t getCurrentTime() override { + return _epoch + static_cast(millis() - _at) / 1000; + } + void setCurrentTime(uint32_t epoch) override { + writes.push_back({millis(), getCurrentTime(), epoch}); + _epoch = epoch; + _at = millis(); + } +}; + +std::string nmea(const std::string& body) { + const std::string line = "$" + body; + char checksum[3] = {}; + MicroNMEA::generateChecksum(line.c_str(), checksum); + const std::string result = line + "*" + checksum + "\r\n"; + EXPECT_TRUE(MicroNMEA::testChecksum(result.c_str())); + return result; +} + +std::string gga(const std::string& time) { + return nmea("GPGGA," + time + + ",4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"); +} + +std::string rmc(const std::string& time, + const std::string& date = "171026") { + return nmea("GPRMC," + time + + ",A,4807.038,N,01131.000,E,0.0,0.0," + date + ",,,A"); +} + +std::string pair(const std::string& time, + const std::string& date = "171026") { + return gga(time) + rmc(time, date); +} + +class GpsRig { + bool _first = true; + uint32_t _last_check = 0; + +public: + GpsInput uart; + AdvancingRtc rtc; + MicroNMEALocationProvider gps; + + explicit GpsRig(bool clock = true) + : gps(uart, clock ? &rtc : nullptr, -1, -1) { + resetArduinoMock(); + gps.begin(); + } + + uint32_t nextDeadline() const { + return _first ? 0 : _last_check + 1000; + } + + // Feed on (not after) the strict deadline so even a two-loop byte-budget + // drain completes before the validation tick. No private provider access. + void receiveWithoutCheck(const std::string& data) { + g_mock_millis = nextDeadline(); + uart.receive(data); + while (uart.available()) gps.loop(); + } + + void checkAt(uint32_t at) { + g_mock_millis = at; + gps.loop(); + _first = false; + _last_check = at; + } + + void window(const std::string& data = {}) { + const uint32_t deadline = nextDeadline(); + receiveWithoutCheck(data); + checkAt(deadline + 1); + } + + void acquire() { + window(pair("120000.00")); + window(pair("120001.00")); + window(pair("120002.00")); + } + + void idleUntilPeriodicDue() { + const uint32_t applied_at = rtc.writes.back().at; + while (static_cast(millis() - applied_at) < 1800000UL) + window(); + } +}; + +} // namespace + +TEST(GpsClockFreshness, ScopedUtcAdapterAndPinnedParserHaveKnownReferenceValues) { + EXPECT_STREQ("2.0.6", MICRONMEA_VERSION); + EXPECT_EQ(NOON_2026_10_17, DateTime(2026, 10, 17, 12, 0, 0).unixtime()); + EXPECT_EQ(MIDNIGHT_2026_10_18, DateTime(2026, 10, 18, 0, 0, 0).unixtime()); + EXPECT_EQ(1709251200UL, DateTime(2024, 3, 1, 0, 0, 0).unixtime()); +} + +TEST(GpsClockFreshness, StartupSilenceCannotReuseOneObservationThreeTimes) { + GpsRig rig; + rig.window(pair("120000.00")); + for (unsigned i = 0; i < 5; ++i) rig.window(); + EXPECT_TRUE(rig.rtc.writes.empty()); + EXPECT_TRUE(rig.gps.waitingTimeSync()); + EXPECT_EQ(0U, rig.gps.getTimeSyncGeneration()); + rig.window(pair("120006.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120007.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 7, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, ThreeOneHzObservationsApplyExactlyOnce) { + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120002.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 2, rig.rtc.writes.back().after); + EXPECT_EQ(1U, rig.gps.getTimeSyncGeneration()); + EXPECT_FALSE(rig.gps.waitingTimeSync()); + EXPECT_TRUE(rig.gps.consumeTimeSyncApplied()); + EXPECT_FALSE(rig.gps.consumeTimeSyncApplied()); + rig.window(pair("120003.00")); + EXPECT_EQ(1U, rig.rtc.writes.size()); +} + +TEST(GpsClockFreshness, FiveSecondReceiverRetainsProgressThroughEmptyWindows) { + GpsRig rig; + rig.window(pair("120000.00")); + for (unsigned i = 0; i < 4; ++i) rig.window(); + rig.window(pair("120005.00")); + for (unsigned i = 0; i < 4; ++i) rig.window(); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120010.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 10, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, SeparateCompleteObservationsMayShareUtcResolution) { + GpsRig rig; + for (unsigned i = 0; i < 3; ++i) rig.window(pair("120000.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, MultipleQueuedRmcSentencesCountOnlyOneValidation) { + GpsRig rig; + rig.window(gga("120000.00") + rmc("120000.00") + + rmc("120001.00") + rmc("120002.00")); + rig.window(); + rig.window(); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120005.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120006.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 6, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, PeriodicSilenceCannotRewindAdvancingRtc) { + GpsRig rig; + rig.acquire(); + ASSERT_EQ(1U, rig.rtc.writes.size()); + rig.idleUntilPeriodicDue(); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_GE(rig.rtc.getCurrentTime(), NOON_2026_10_17 + 1802); + EXPECT_TRUE(rig.gps.waitingTimeSync()); + // A warm receiver remains validated; one new observation is sufficient. + rig.window(pair("123003.00")); + ASSERT_EQ(2U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 1803, rig.rtc.writes.back().after); + EXPECT_EQ(2U, rig.gps.getTimeSyncGeneration()); +} + +TEST(GpsClockFreshness, MidIntervalObservationIsConsumedBeforePeriodicDeadline) { + GpsRig rig; + rig.acquire(); + ASSERT_EQ(1U, rig.rtc.writes.size()); + rig.window(pair("120003.00")); + rig.idleUntilPeriodicDue(); + EXPECT_EQ(1U, rig.rtc.writes.size()); +} + +TEST(GpsClockFreshness, CandidateCannotSurviveThirtyMinuteLoopPause) { + GpsRig rig; + rig.acquire(); + ASSERT_EQ(1U, rig.rtc.writes.size()); + rig.receiveWithoutCheck(pair("120003.00")); + const uint32_t observed_at = millis(); + rig.checkAt(observed_at + 1800001UL); + EXPECT_EQ(1U, rig.rtc.writes.size()); + EXPECT_TRUE(rig.gps.waitingTimeSync()); + rig.window(pair("123004.00")); + ASSERT_EQ(2U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 1804, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, CandidateAgeAllowsTwoSecondsButNotAnOlderObservation) { + for (uint32_t age : {2000U, 2001U}) { + SCOPED_TRACE(age); + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.receiveWithoutCheck(pair("120002.00")); + const uint32_t observed_at = millis(); + rig.checkAt(observed_at + age); + EXPECT_EQ(age == 2000 ? 1U : 0U, rig.rtc.writes.size()); + if (age == 2001) { + 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, FreshGgaAfterMidnightCannotAuthorizeYesterdayDate) { + GpsRig rig; + rig.window(pair("235956.00")); + rig.window(pair("235957.00")); + rig.window(pair("235958.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(MIDNIGHT_2026_10_18 - 2, rig.rtc.writes.back().after); + rig.idleUntilPeriodicDue(); + rig.window(gga("002958.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_GE(rig.rtc.getCurrentTime(), MIDNIGHT_2026_10_18 + 1798); + rig.window(pair("003000.00", "181026")); + ASSERT_EQ(2U, rig.rtc.writes.size()); + EXPECT_EQ(MIDNIGHT_2026_10_18 + 1800, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, RmcCandidateIsNotChangedByLaterGgaInSameWindow) { + GpsRig rig; + rig.window(pair("235956.00")); + rig.window(pair("235957.00")); + // The complete RMC carries the 17th, followed by GGA from the 18th. The + // parser's live timestamp is now hybrid, but the captured candidate is not. + rig.window(rmc("235958.00") + gga("000000.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(MIDNIGHT_2026_10_18 - 2, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, BadChecksumAndInvalidCalendarDatesCannotCount) { + for (bool bad_checksum : {false, true}) { + SCOPED_TRACE(bad_checksum); + GpsRig rig; + for (unsigned i = 0; i < 3; ++i) { + std::string observation = rmc("120000.00", bad_checksum ? "171026" : "290226"); + if (bad_checksum) { + const size_t checksum = observation.find('*') + 1; + observation[checksum] = observation[checksum] == '0' ? '1' : '0'; + ASSERT_FALSE(MicroNMEA::testChecksum(observation.c_str())); + } + rig.window(gga("120000.00") + observation); + } + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.acquire(); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 2, rig.rtc.writes.back().after); + } +} + +TEST(GpsClockFreshness, TrackerRequiresFreshObservationsInConsecutiveWindows) { + GpsRig rig; + rig.gps.beginFreshTimeSync(); + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.window(); + 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")); + rig.window(pair("120001.00")); + rig.receiveWithoutCheck(pair("120002.00")); + rig.gps.syncTime(); + rig.window(); + rig.window(pair("120004.00")); + rig.window(pair("120005.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120006.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_EQ(NOON_2026_10_17 + 6, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, ClearPositionResetsCountCandidateAndQueuedInput) { + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.receiveWithoutCheck(pair("120002.00")); + rig.uart.receive(pair("120003.00")); + ASSERT_TRUE(rig.gps.clearPositionFix()); + EXPECT_EQ(0, rig.uart.available()); + rig.window(); + rig.window(pair("120004.00")); + rig.window(pair("120005.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120006.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); +} + +TEST(GpsClockFreshness, FreshRequestDiscardsEarlierNormalValidationProgress) { + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.gps.beginFreshTimeSync(); + // beginFreshTimeSync reanchors next_check to the current millisecond. Check + // that newly pending request once, then resume the regular strict cadence. + rig.checkAt(millis() + 1); + 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()); +} + +TEST(GpsClockFreshness, NullClockNeverReportsAnAppliedSync) { + GpsRig rig(false); + rig.acquire(); + for (unsigned i = 0; i < 10; ++i) rig.window(pair("120003.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + EXPECT_EQ(0U, rig.gps.getTimeSyncGeneration()); + EXPECT_FALSE(rig.gps.consumeTimeSyncApplied()); + EXPECT_TRUE(rig.gps.waitingTimeSync()); +} + +TEST(GpsClockFreshness, WarmValidationHistorySaturatesInsteadOfWrapping) { + GpsRig rig; + 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")); + rig.idleUntilPeriodicDue(); + EXPECT_EQ(1U, rig.rtc.writes.size()); + rig.window(pair("123003.00")); + ASSERT_EQ(2U, rig.rtc.writes.size()); +} + +TEST(GpsClockFreshness, TrackerValidationCadenceSurvivesMillisRollover) { + GpsRig rig; + g_mock_millis = UINT32_MAX - 3000; + rig.gps.beginFreshTimeSync(); + rig.checkAt(millis() + 1); + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + EXPECT_TRUE(rig.rtc.writes.empty()); + rig.window(pair("120002.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + EXPECT_LT(millis(), 1000U); + EXPECT_EQ(NOON_2026_10_17 + 2, rig.rtc.writes.back().after); +} + +TEST(GpsClockFreshness, CandidateAgeLimitIsMeasuredAcrossMillisRollover) { + for (uint32_t age : {2000U, 2001U}) { + SCOPED_TRACE(age); + GpsRig rig; + g_mock_millis = UINT32_MAX - 4000; + rig.gps.beginFreshTimeSync(); + rig.checkAt(millis() + 1); + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.receiveWithoutCheck(pair("120002.00")); + const uint32_t observed_at = millis(); + ASSERT_GT(observed_at, UINT32_MAX - 1000); + rig.checkAt(observed_at + age); + EXPECT_LT(millis(), 2000U); + EXPECT_EQ(age == 2000 ? 1U : 0U, rig.rtc.writes.size()); + } +} + +TEST(GpsClockFreshness, MissingRmcDateCannotAuthorizeRetainedPreviousDay) { + GpsRig rig; + rig.window(pair("235956.00")); + rig.window(pair("235957.00")); + rig.window(pair("235958.00")); + ASSERT_EQ(1U, rig.rtc.writes.size()); + rig.idleUntilPeriodicDue(); + // Checksum and new time are valid, but MicroNMEA retains the 17th's date. + rig.window(rmc("002958.00", "")); + EXPECT_EQ(1U, rig.rtc.writes.size()); + EXPECT_GE(rig.rtc.getCurrentTime(), MIDNIGHT_2026_10_18 + 1798); +} + +TEST(GpsClockFreshness, MissingRmcTimeCannotCountRetainedTimeWithNewDate) { + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + // A new date alone must not reclassify the previous time as an observation. + rig.window(rmc("", "181026")); + EXPECT_TRUE(rig.rtc.writes.empty()); + EXPECT_EQ(0U, rig.gps.getTimeSyncGeneration()); +} + +TEST(GpsClockFreshness, PartialOrNonNumericRmcClockFieldsCannotCount) { + const std::vector> malformed = { + {"12000", "171026"}, {"12", "171026"}, {"1", "171026"}, + {"120002.00", "17102"}, {"120002.00", "17"}, + {"120002.00", "1"}, {"120002.", "171026"}, + {"120002.0x", "171026"}, {"120002.00", "171026x"}}; + for (const auto& fields : malformed) { + SCOPED_TRACE(fields.first + "/" + fields.second); + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.window(rmc(fields.first, fields.second)); + EXPECT_TRUE(rig.rtc.writes.empty()); + EXPECT_EQ(0U, rig.gps.getTimeSyncGeneration()); + } +} + +TEST(GpsClockFreshness, CompleteRmcTimeAllowsSupportedDecimalSeconds) { + for (const char* time : {"120002.00", "120002.000", "120002.123456"}) { + SCOPED_TRACE(time); + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + rig.window(gga("120002.00") + rmc(time)); + EXPECT_EQ(1U, rig.rtc.writes.size()); + if (rig.rtc.writes.size() != 1) continue; + EXPECT_EQ(NOON_2026_10_17 + 2, rig.rtc.writes.back().after); + } +} + +TEST(GpsClockFreshness, PinnedParserShortTimeFractionsAreRejectedThenDecimalRecovers) { + for (const char* time : {"120002", "120002.0"}) { + SCOPED_TRACE(time); + GpsRig rig; + rig.window(pair("120000.00")); + rig.window(pair("120001.00")); + // MicroNMEA 2.0.6 skips a fixed nine time characters before seeking the + // next field. No fraction or one fractional digit is not a provider fix. + // Reject these formats rather than reusing retained clock data; accepting + // them requires a separate parser/location compatibility fix. + rig.window(gga("120002.00") + rmc(time)); + EXPECT_FALSE(rig.gps.isValid()); + EXPECT_TRUE(rig.rtc.writes.empty()); + 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); + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_gps_upstream_adaptations.py b/test/test_gps_upstream_adaptations.py index 768ad0923..c05962df9 100644 --- a/test/test_gps_upstream_adaptations.py +++ b/test/test_gps_upstream_adaptations.py @@ -71,7 +71,7 @@ public: bool process(char c){if(c=='\n'){valid=true;return true;}return false;} static bool testChecksum(const char*){return true;} static void sendSentence(Stream&,const char*){} - const char* getSentence()const{return "$GPRMC,valid*00";} + const char* getSentence()const{return "$GPRMC,120000.00,A,4807.038,N,01131.000,E,0.0,0.0,021026,,,A*00";} const char* getMessageID()const{return "RMC";} bool isValid()const{return valid;} long getLatitude()const{return 47610000;} @@ -191,7 +191,7 @@ int main(){ // Successful production NMEA sync marks monotonic time and leaves the edge // notification available for existing mesh clock consumers. - now_ms=1100;gps.loop();now_ms=2200;gps.loop(); + now_ms=1100;uart.fix();gps.loop();now_ms=2200;uart.fix();gps.loop(); assert(!gps.waitingTimeSync()); assert(gps.consumeTimeSyncApplied());assert(!gps.consumeTimeSyncApplied()); now_ms=62199;assert(!gps.requestTimeSync(60)); @@ -573,7 +573,12 @@ int main(){ self.assertIn("resetTimeSyncRequestState();", rak_provider) # Explicit CLI sync remains forced; scheduled power cycles honor the # configured hourly cadence, with force-sync retained for legacy0. - self.assertIn("void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }", provider) + force_sync = extract_braced(provider, "void syncTime() override") + self.assertIn("nmea.clear();", force_sync) + self.assertIn("time_valid = 0;", force_sync) + self.assertIn("_fresh_clock_sample = false;", force_sync) + self.assertLess(force_sync.index("nmea.clear();"), + force_sync.index("LocationProvider::syncTime();")) self.assertIn("_location->syncTimeForPowerSavingCycle();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()")) diff --git a/test/test_rak_gps_clock_freshness.py b/test/test_rak_gps_clock_freshness.py new file mode 100644 index 000000000..a50eaf362 --- /dev/null +++ b/test/test_rak_gps_clock_freshness.py @@ -0,0 +1,142 @@ +"""Run the actual RAK12500 provider against retained-PVT GNSS/RTC adapters. + +The actual full provider class and LocationProvider header are compiled. The +GNSS adapter models SparkFun's per-field unread flags and retained data after +failed polls; it is not an I2C/parser or hardware test. A separate installed- +library probe established that contract without making CI depend on libdeps. +""" +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / 'test/fixtures/rak_gps_clock_freshness.cpp' + +ARDUINO = r''' +#pragma once +#include +#include +#include +inline uint32_t now_ms = 0; +inline uint32_t millis() { return now_ms; } +inline constexpr int HIGH = 1; +inline int digitalRead(int) { return HIGH; } +''' + +MESH = r''' +#pragma once +#include +namespace mesh { +class RTCClock { + uint32_t utc = 1772323200U, anchored = 0; +public: + unsigned writes = 0; + uint32_t getCurrentTime() { return utc + (now_ms - anchored) / 1000U; } + void setCurrentTime(uint32_t value) { utc = value; anchored = now_ms; ++writes; } +}; +} +''' + + +class RakGpsClockFreshnessTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which('g++') or shutil.which('clang++') + if compiler is None: + raise unittest.SkipTest('a host C++17 compiler is required') + temporary = tempfile.TemporaryDirectory(prefix='rak-gps-clock-') + cls.addClassCleanup(temporary.cleanup) + work = Path(temporary.name) + (work / 'Arduino.h').write_text(ARDUINO, encoding='ascii') + (work / 'Mesh.h').write_text(MESH, encoding='ascii') + source = (ROOT / 'src/helpers/sensors/EnvironmentSensorManager.cpp').read_text( + encoding='utf-8') + provider = extract_braced(source, 'class RAK12500LocationProvider') + ';\n' + loop = extract_braced(provider, 'void loop() override') + failed_poll = extract_braced(loop, 'if (!ublox_GNSS.getPVT(8))') + start = loop.index('ublox_GNSS.flushPVT();') + end = loop.index(failed_poll) + len(failed_poll) + admission = loop[start:end] + if admission.count('ublox_GNSS.getPVT(8)') != 1: + raise AssertionError('expected one actual explicit PVT admission') + # Restore the historical normal-operation omission, without referring + # to its now-removed position-request flag or copying a stale provider. + legacy_loop = loop[:start] + 'if (_fresh_time_sync_required) {\n' + admission \ + + '\n }' + loop[end:] + legacy = provider.replace(loop, legacy_loop, 1) + fixture = FIXTURE.read_text(encoding='ascii') + sanitize = [] if not sys.platform.startswith('linux') else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-omit-frame-pointer', '-fno-pie', '-no-pie'] + cls.binaries = {} + for kind, actual in (('production', provider), ('legacy', legacy)): + cpp = work / (kind + '.cpp') + cpp.write_text(fixture.replace('@PROVIDER@', actual), encoding='utf-8') + binary = work / (kind + ('.exe' if os.name == 'nt' else '')) + built = subprocess.run([ + compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror', + '-Wno-unused-parameter', *sanitize, '-I' + str(work), + '-I' + str(ROOT / 'src'), str(cpp), '-o', str(binary)], + capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + cls.binaries[kind] = binary + + def check_case(self, case, kind='production', diagnostic=None): + checked = subprocess.run([str(self.binaries[kind]), case], + capture_output=True, text=True, timeout=10) + if diagnostic is not None: + self.assertNotEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(diagnostic, checked.stderr) + self.assertNotIn('runtime error:', checked.stderr) + self.assertNotIn('AddressSanitizer:', checked.stderr) + else: + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn('PASS ' + case, checked.stdout) + + def test_one_startup_pvt_cannot_be_counted_again_after_poll_failure(self): + self.check_case('startup-silence') + + def test_periodic_resync_does_not_rewind_clock_from_retained_pvt(self): + self.check_case('periodic-silence') + + def test_poll_failure_resets_consecutive_count_and_three_new_samples_recover(self): + self.check_case('consecutive-failure') + + def test_success_failure_and_throttled_calls_have_bounded_poll_and_getter_cost(self): + self.check_case('poll-budget') + + def test_tracker_position_and_fresh_time_requests_require_new_pvt(self): + for case in ('position-request', 'fresh-tracker'): + with self.subTest(case=case): + self.check_case(case) + + def test_reset_cannot_reuse_retained_receiver_cache(self): + self.check_case('reset') + + def test_null_clock_still_updates_position_without_time_side_effects(self): + self.check_case('null-clock') + + def test_unqualified_fresh_frames_reset_consecutive_time_samples(self): + for field in ('fix', 'date', 'time', 'sats', 'epoch'): + with self.subTest(field=field): + self.check_case('invalid-' + field) + + def test_adapter_retains_cache_on_failed_explicit_and_implicit_polls(self): + self.check_case('boundary-contract') + + def test_historical_omission_fails_clock_oracles_but_preserves_fresh_tracker(self): + for case in ('startup-silence', 'periodic-silence'): + with self.subTest(case=case): + self.check_case(case, 'legacy', 'stale PVT must not update the clock') + self.check_case('fresh-tracker', 'legacy') + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_sensor_tracker_gps.py b/test/test_sensor_tracker_gps.py index ad7d6666f..9af4542ed 100644 --- a/test/test_sensor_tracker_gps.py +++ b/test/test_sensor_tracker_gps.py @@ -266,9 +266,12 @@ struct Gnss { @RAK_PROVIDER@; int main(){ now_ms=1;RAK12500LocationProvider provider; + ublox_GNSS.available=true; // Establish the retained cache with a received PVT. provider.loop();assert(provider.isValid()); + const unsigned flushes=ublox_GNSS.flushes; assert(provider.clearPositionFix());assert(!provider.isValid()); - assert(ublox_GNSS.flushes==1&&ublox_GNSS.stale); + assert(ublox_GNSS.flushes==flushes+1&&ublox_GNSS.stale); + ublox_GNSS.available=false; provider.loop();assert(!provider.isValid()); ublox_GNSS.available=true;now_ms+=1000; provider.loop();assert(provider.isValid()&&provider.getLatitude()==47610000);