Require fresh complete GPS observations before clock synchronization

This commit is contained in:
mikecarper committed 2026-10-16 19:48:34 -07:00
1 parent 31a53b1f4a
commit 135d361368
9 files changed
+1013 -30

No files matched your search

+1
View File
@@ -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
@@ -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;
+75 -12
View File
@@ -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<uint32_t>(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<int32_t>(now - static_cast<uint32_t>(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<uint32_t>(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<uint32_t>(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();
+241
View File
@@ -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 <array>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#include <helpers/sensors/LocationProvider.h>
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<unsigned, FIELDS> reads{};
std::vector<uint16_t> 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<uint8_t>(1U << field);
if (!(unread & mask)) (void)getPVT(max_wait);
unread &= static_cast<uint8_t>(~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<uint32_t>(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);
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <stdint.h>
// 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;
}
};
@@ -0,0 +1,503 @@
#include <gtest/gtest.h>
#include <Arduino.h>
#include <deque>
#include <string>
#include <utility>
#include <vector>
void yield() { ++g_mock_millis; }
// This includes the actual provider, LocationProvider and pinned native parser.
#include <helpers/sensors/MicroNMEALocationProvider.h>
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<uint8_t> incoming;
void receive(const std::string& text) {
for (unsigned char c : text) incoming.push_back(c);
}
int available() override { return static_cast<int>(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<Write> writes;
uint32_t getCurrentTime() override {
return _epoch + static_cast<uint32_t>(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<uint32_t>(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<std::pair<std::string, std::string>> 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();
}
+8 -3
View File
@@ -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()"))
+142
View File
@@ -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 <cstdint>
#include <cstddef>
#include <cstring>
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 <Arduino.h>
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()
+4 -1
View File
@@ -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);