Require fresh complete GPS observations before clock synchronization
This commit is contained in:
1 parent
31a53b1f4a
commit
135d361368
9 files changed
+1013
-30
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()"))
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user