Skip to content

Commit 2024bb8

Browse files
authored
Arrival time fix perhaps (#11274)
* Add explicit presence for MeshPacket.rx_time (arrival time) rx_time is now proto3 optional with a has_rx_time presence bit, matching the rx_rssi treatment. A node with no GPS and no phone connected yet has no time source at all, so a bare 0 was indistinguishable from a genuine 1970-01-01 reading; downstream consumers (replay packets, JSON serialization) now check has_rx_time instead of the value. * Dedupe rx_time stamping into a shared helper; trim a debug log string Extract the repeated haveTime/rx_time/has_rx_time stamp logic (5 call sites across Router.cpp, MeshBeaconModule.cpp, MeshService.cpp) into Router::computeRxTimeStamp()/stampRxTime(). Also shorten the new RTC.cpp LOG_DEBUG string. Saves 48 bytes of flash on rak4631 (measured), no behavior change. * Fix has_rx_rssi presence carried unconditionally through StoreForward replay preparePayload() set has_rx_rssi = true unconditionally on replay, regardless of whether the packet's rx_rssi at store time was a genuine measurement (e.g. MQTT-relayed packets carry no real RSSI). Store the presence bit alongside rx_rssi in PacketHistoryStruct and restore it on replay instead. Flagged by Copilot on #11271 (same root cause the has_rx_time explicit presence work fixes) but never addressed before that PR merged. * Trim comment blocks to the repo's 1-2 line guideline .github/copilot-instructions.md:338 caps code comments at 1-2 lines; several blocks added across the rx_time explicit-presence work ran well past that. Also consolidates Time.cpp's file-level doc comment into Time.h, where the rest of the Time:: API contract already lives. No behavior change. * Add rx_time explicit-presence test coverage - test_meshpacket_serializer: has_rx_time=false fixture plus tests asserting JsonSerialize/JsonSerializeEncrypted emit 0 rather than leaking the millis() placeholder, alongside the has_rx_time=true baseline. - test_stream_api: two tests driving a real PhoneAPI handshake (want_config_id through STATE_SEND_PACKETS) that simulate a phone time-giving transaction arriving before vs. after a queued packet is drained - covering both the reconciled and the ships-with-placeholder-absent paths of MeshService::reconcilePendingRxTimes(). * Fix three correctness issues flagged in review - Time.h: drop the reserved-identifier include guard (_MT_TIME_H); pragma once already covers it, matching convention elsewhere (e.g. RTC.h). - Time.cpp: rebase getMillis64()'s wrap accumulator when the test seam swaps clock sources, so a real<->injected clock jump isn't miscounted as a genuine 32-bit wrap. - NodeInfoModule: the 12h reply-suppression window is a local dedup duration, not a wall-clock reading - switch it to Time::getMillis64() so RTC-quality jumps and replayed packets' stale rx_time can't perturb it. - StoreForwardModule: has_rx_time was derived from *current* RTC quality at replay time rather than stored at capture time, so a history entry saved while time-blind could be misreported as a valid epoch once the clock later improved. Persist the presence bit in PacketHistoryStruct instead. * tryfix CI * post review fixes * more test fixes
1 parent 0fef83d commit 2024bb8

26 files changed

Lines changed: 436 additions & 32 deletions

protobufs

src/UptimeClock.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// See UptimeClock.h for the full contract.
2+
#include "UptimeClock.h"
3+
#include <Arduino.h>
4+
5+
uint32_t Time::getMillis()
6+
{
7+
#ifdef PIO_UNIT_TESTING
8+
if (Time::useTestClock)
9+
return Time::testNowMs;
10+
#endif
11+
return millis();
12+
}
13+
14+
uint64_t Time::getMillis64()
15+
{
16+
static uint32_t lastLow = 0; // last 32-bit sample
17+
static uint32_t highWord = 0; // number of observed wraps
18+
19+
uint32_t now = Time::getMillis();
20+
#ifdef PIO_UNIT_TESTING
21+
// A test swapping clock sources (real <-> injected) can make `now` jump backward for
22+
// reasons other than a genuine wrap - rebase rather than miscount it as one.
23+
if (Time::clockSourceChanged) {
24+
lastLow = now;
25+
highWord = 0;
26+
Time::clockSourceChanged = false;
27+
}
28+
#endif
29+
if (now < lastLow)
30+
highWord++; // low word wrapped since last call
31+
lastLow = now;
32+
return (static_cast<uint64_t>(highWord) << 32) | now;
33+
}

src/UptimeClock.h

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#pragma once
2+
3+
#include <cstdint>
4+
5+
// Monotonic uptime clock, injectable so tests can drive a virtual timebase instead of sleeping.
6+
// Uptime only; see gps/RTC.h for wall-clock. Not named Time.h: -Isrc would shadow C's <time.h>.
7+
namespace Time
8+
{
9+
#ifdef PIO_UNIT_TESTING
10+
// Test-only virtual clock; OFF by default so suites relying on real time are unaffected.
11+
inline uint32_t testNowMs = 0;
12+
inline bool useTestClock = false;
13+
inline bool clockSourceChanged = true; // forces getMillis64() to rebase its wrap accumulator
14+
15+
inline void setTestMillis(uint32_t ms)
16+
{
17+
testNowMs = ms;
18+
useTestClock = true;
19+
clockSourceChanged = true;
20+
}
21+
inline void advanceTestMillis(uint32_t deltaMs)
22+
{
23+
// Advancing from 0 after getMillis64() sampled the real clock steps backward, which would
24+
// otherwise be miscounted as a wrap.
25+
if (!useTestClock)
26+
clockSourceChanged = true;
27+
testNowMs += deltaMs;
28+
useTestClock = true;
29+
}
30+
// Restore real-clock behaviour (call in test tearDown if a suite mixes real and fake time).
31+
inline void useRealClock()
32+
{
33+
useTestClock = false;
34+
testNowMs = 0;
35+
clockSourceChanged = true;
36+
}
37+
#endif
38+
39+
/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis().
40+
uint32_t getMillis();
41+
42+
/// Milliseconds since boot, 64-bit, rollover-immune. Must be polled at least once per ~49.7-day
43+
/// wrap window to catch every wrap, and keeps mutable static carry state, so it is NOT ISR-safe.
44+
uint64_t getMillis64();
45+
46+
} // namespace Time

src/gps/RTC.cpp

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "configuration.h"
33
#include "detect/ScanI2C.h"
44
#include "main.h"
5+
#include "mesh/MeshService.h"
56
#include "modules/NodeInfoModule.h"
67
#include <Throttle.h>
78
#include <sys/time.h>
@@ -17,12 +18,16 @@ uint32_t lastSetFromPhoneNtpOrGps = 0;
1718
static uint32_t lastTimeValidationWarning = 0;
1819
static const uint32_t TIME_VALIDATION_WARNING_INTERVAL_MS = 15000; // 15 seconds
1920

20-
static void triggerNodeInfoCheckOnTimeSource(RTCQuality oldQuality, RTCQuality newQuality)
21+
static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQuality)
2122
{
2223
if (oldQuality == RTCQualityNone && newQuality > RTCQualityNone && nodeInfoModule) {
2324
LOG_DEBUG("Time source acquired (%s -> %s), triggering NodeInfo recheck", RtcName(oldQuality), RtcName(newQuality));
2425
nodeInfoModule->triggerImmediateNodeInfoCheck();
2526
}
27+
if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) {
28+
LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality));
29+
service->reconcilePendingRxTimes();
30+
}
2631
}
2732

2833
RTCQuality getRTCQuality()
@@ -128,7 +133,7 @@ RTCSetResult readFromRTC()
128133
timeStartMsec = now;
129134
zeroOffsetSecs = tv.tv_sec;
130135
currentQuality = RTCQualityDevice;
131-
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
136+
onTimeSourceQualityChanged(oldQuality, currentQuality);
132137
}
133138
return RTCSetResultSuccess;
134139
} else {
@@ -174,7 +179,7 @@ RTCSetResult readFromRTC()
174179
timeStartMsec = now;
175180
zeroOffsetSecs = tv.tv_sec;
176181
currentQuality = RTCQualityDevice;
177-
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
182+
onTimeSourceQualityChanged(oldQuality, currentQuality);
178183
}
179184
return RTCSetResultSuccess;
180185
} else {
@@ -210,7 +215,7 @@ RTCSetResult readFromRTC()
210215
timeStartMsec = now;
211216
zeroOffsetSecs = tv.tv_sec;
212217
currentQuality = RTCQualityDevice;
213-
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
218+
onTimeSourceQualityChanged(oldQuality, currentQuality);
214219
}
215220
return RTCSetResultSuccess;
216221
}
@@ -235,7 +240,7 @@ RTCSetResult readFromRTC()
235240
timeStartMsec = now;
236241
zeroOffsetSecs = tv.tv_sec;
237242
currentQuality = RTCQualityDevice;
238-
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
243+
onTimeSourceQualityChanged(oldQuality, currentQuality);
239244
}
240245
return RTCSetResultSuccess;
241246
}
@@ -381,7 +386,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
381386
#endif
382387

383388
readFromRTC();
384-
triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality);
389+
onTimeSourceQualityChanged(oldQuality, currentQuality);
385390
return RTCSetResultSuccess;
386391
} else {
387392
return RTCSetResultNotSet; // RTC was already set with a higher quality time

src/mesh/MeshService.cpp

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "Power.h"
1313
#include "PowerFSM.h"
1414
#include "TypeConversions.h"
15+
#include "UptimeClock.h"
1516
#include "gps/RTC.h"
1617
#include "graphics/draw/MessageRenderer.h"
1718
#include "main.h"
@@ -180,6 +181,36 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id)
180181
return nodenum;
181182
}
182183

184+
// Back-calculate the real epoch for any queued packet still carrying a millis() rx_time
185+
// placeholder, now that the clock is trustworthy.
186+
void MeshService::reconcilePendingRxTimes()
187+
{
188+
const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
189+
if (nowEpoch == 0) // called before the clock was actually valid - nothing to reconcile against
190+
return;
191+
const uint32_t nowMillis = Time::getMillis();
192+
193+
// Rotate the queue once. TypedQueue is strictly FIFO on both backends, so dequeueing and
194+
// re-enqueueing every element in turn leaves the delivery order unchanged.
195+
for (int remaining = toPhoneQueue.numUsed(); remaining > 0; remaining--) {
196+
meshtastic_MeshPacket *p = toPhoneQueue.dequeuePtr(0);
197+
if (!p) // drained from under us - nothing left to rotate
198+
break;
199+
if (!p->has_rx_time) {
200+
// Unsigned subtraction is wraparound-safe; rx_time is a 32-bit wire field, so the
201+
// placeholder was never wider than 32 bits to begin with.
202+
const uint32_t elapsedMs = nowMillis - p->rx_time;
203+
p->rx_time = nowEpoch - (elapsedMs / 1000);
204+
p->has_rx_time = true;
205+
}
206+
if (!toPhoneQueue.enqueue(p, 0)) { // mirrors sendToPhone()'s degrade-on-failure path
207+
LOG_CRIT("Failed to requeue a packet into toPhoneQueue!");
208+
releaseToPool(p);
209+
fromNum++; // notify observers so the phone can resync
210+
}
211+
}
212+
}
213+
183214
#if MESHTASTIC_ENABLE_FRAME_INJECTION
184215
// Deliver a client-supplied frame into the receive pipeline as if it arrived off the LoRa chip. Mirrors
185216
// the portduino SimRadio SIMULATOR_APP unwrap so the same host wire format works on real hardware: the
@@ -220,7 +251,9 @@ void MeshService::injectAsReceived(meshtastic_MeshPacket &p)
220251
mp->rx_rssi = -40;
221252
mp->has_rx_rssi = true;
222253
}
223-
mp->rx_time = getValidTime(RTCQualityFromNet);
254+
// dispatchReceived() restamps this when the packet re-enters the pipeline below; stamp it
255+
// here anyway so the packet is never observable with an unset arrival time.
256+
stampRxTime(mp);
224257
LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel,
225258
mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded");
226259
router->enqueueReceivedMessage(mp);
@@ -258,7 +291,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p)
258291
if (p.id == 0)
259292
p.id = generatePacketId(); // If the phone didn't supply one, then pick one
260293

261-
p.rx_time = getValidTime(RTCQualityFromNet); // Record the time the packet arrived from the phone
294+
// Record the time the packet arrived from the phone.
295+
stampRxTime(&p);
262296

263297
IF_SCREEN(if (p.decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP && p.decoded.payload.size > 0 &&
264298
p.to != NODENUM_BROADCAST && p.to != 0) // DM only
@@ -595,6 +629,11 @@ bool MeshService::isToPhoneQueueEmpty()
595629

596630
uint32_t MeshService::GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp)
597631
{
632+
// rx_time may be a millis() placeholder while has_rx_time is false - don't age it as
633+
// wall-clock, and don't pass it off as "just now" either.
634+
if (!mp->has_rx_time)
635+
return SINCE_UNKNOWN;
636+
598637
uint32_t now = getTime();
599638

600639
uint32_t last_seen = mp->rx_time;

src/mesh/MeshService.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ class MeshService
137137
// search the queue for a request id and return the matching nodenum
138138
NodeNum getNodenumFromRequestId(uint32_t request_id);
139139

140+
// Rewrite any queued-for-phone packet still carrying a millis() rx_time placeholder into a
141+
// real epoch, now that the wall clock is trustworthy.
142+
void reconcilePendingRxTimes();
143+
140144
// Release QueueStatus packet to pool
141145
void releaseQueueStatusToPool(meshtastic_QueueStatus *p) { queueStatusPool.release(p); }
142146

@@ -205,6 +209,7 @@ class MeshService
205209

206210
ErrorCode sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, ErrorCode res, uint32_t mesh_packet_id);
207211

212+
/// Seconds since the packet arrived, or SINCE_UNKNOWN if it carries no trustworthy rx_time.
208213
uint32_t GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp);
209214

210215
private:

src/mesh/NodeDB.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3237,6 +3237,11 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n)
32373237

32383238
uint32_t sinceReceived(const meshtastic_MeshPacket *p)
32393239
{
3240+
// rx_time may be a millis() placeholder while has_rx_time is false - don't age it as
3241+
// wall-clock, and don't pass it off as "just now" either.
3242+
if (!p->has_rx_time)
3243+
return SINCE_UNKNOWN;
3244+
32403245
uint32_t now = getTime();
32413246

32423247
int delta = (int)(now - p->rx_time);
@@ -3633,7 +3638,8 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
36333638
return;
36343639
}
36353640

3636-
if (mp.rx_time) // if the packet has a valid timestamp use it to update our last_heard
3641+
// Gate on has_rx_time, not truthiness - rx_time may hold a millis() placeholder.
3642+
if (mp.has_rx_time)
36373643
info->last_heard = mp.rx_time;
36383644

36393645
// Gate on the packet actually having been received over our own radio, not on rx_snr being

src/mesh/NodeDB.h

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,15 @@ inline bool isRadioProfileFile(const char *filename)
166166
strcmp(filename, backupFileName) == 0;
167167
}
168168

169+
/// "No trustworthy arrival time", as distinct from "zero seconds ago". Deliberately huge so the
170+
/// display formatters fall into their existing unknown-age branches ("unknown age" / "?").
171+
inline constexpr uint32_t SINCE_UNKNOWN = UINT32_MAX;
172+
169173
/// Given a node, return how many seconds in the past (vs now) that we last heard from it
170174
uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
171175

172-
/// Given a packet, return how many seconds in the past (vs now) it was received
176+
/// Given a packet, return how many seconds in the past (vs now) it was received,
177+
/// or SINCE_UNKNOWN if it carries no trustworthy rx_time.
173178
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
174179

175180
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.

src/mesh/PhoneAPI.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,6 +1238,17 @@ void setReplayHopFields(meshtastic_MeshPacket &pkt, const meshtastic_NodeInfoLit
12381238
pkt.hop_limit = hopsAway < hopLimit ? (uint8_t)(hopLimit - hopsAway) : 0;
12391239
}
12401240

1241+
/// 2020-01-01: a boot-relative counter needs ~50 years of uptime to reach this, so it cannot be
1242+
/// confused with a real epoch.
1243+
constexpr uint32_t MIN_PLAUSIBLE_EPOCH = 1577836800u;
1244+
1245+
/// Not every last_heard writer gates on RTC quality - NodeDB::addFromContact stamps it with a bare
1246+
/// getTime(), which is boot-relative seconds on a node that has never had a clock.
1247+
bool lastHeardIsWallClock(const meshtastic_NodeInfoLite *header)
1248+
{
1249+
return header && header->last_heard >= MIN_PLAUSIBLE_EPOCH;
1250+
}
1251+
12411252
} // namespace
12421253

12431254
// Replayed packets deliberately leave rx_rssi absent. NodeInfoLite stores no RSSI, and
@@ -1258,6 +1269,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const mesh
12581269
// fix time (which is often 0 and, when present, already round-trips inside the payload
12591270
// via ConvertToPosition).
12601271
pkt.rx_time = header ? header->last_heard : 0;
1272+
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
1273+
pkt.has_rx_time = lastHeardIsWallClock(header);
12611274
// Stable per-node/per-fix id: replaying the same unchanged history on every
12621275
// reconnect must not look like a brand new packet to the phone's history/dedup.
12631276
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP);
@@ -1285,6 +1298,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes
12851298
// No native timestamp on telemetry packets here; use last_heard.
12861299
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
12871300
pkt.rx_time = header ? header->last_heard : 0;
1301+
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
1302+
pkt.has_rx_time = lastHeardIsWallClock(header);
12881303
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag);
12891304
pkt.channel = header ? header->channel : 0;
12901305
pkt.rx_snr = header ? header->snr : 0;
@@ -1391,6 +1406,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const
13911406
pkt.to = NODENUM_BROADCAST;
13921407
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
13931408
pkt.rx_time = header ? header->last_heard : 0;
1409+
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
1410+
pkt.has_rx_time = lastHeardIsWallClock(header);
13941411
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag);
13951412
pkt.channel = header ? header->channel : 0;
13961413
pkt.rx_snr = header ? header->snr : 0;
@@ -1456,6 +1473,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht
14561473
// StatusMessage has no native timestamp; use last_heard.
14571474
const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num);
14581475
pkt.rx_time = header ? header->last_heard : 0;
1476+
// Present only when last_heard is a genuine epoch - see lastHeardIsWallClock().
1477+
pkt.has_rx_time = lastHeardIsWallClock(header);
14591478
pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP);
14601479
pkt.channel = header ? header->channel : 0;
14611480
pkt.rx_snr = header ? header->snr : 0;

src/mesh/RadioInterface.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,7 @@ void printPacket(const char *prefix, const meshtastic_MeshPacket *p)
877877
out += DEBUG_PORT.mt_sprintf(" len=%d", p->encrypted.size + sizeof(PacketHeader));
878878
}
879879

880-
if (p->rx_time != 0)
880+
if (p->has_rx_time) // rx_time has explicit presence; a millis() placeholder isn't a real reading to print
881881
out += DEBUG_PORT.mt_sprintf(" rxtime=%u", p->rx_time);
882882
if (p->rx_snr != 0.0)
883883
out += DEBUG_PORT.mt_sprintf(" rxSNR=%g", p->rx_snr);

0 commit comments

Comments
 (0)