Fix millis() rollover in deadline, interval, and timestamp handling (#11291)

* Add native test coverage for the UptimeClock monotonic seam

src/UptimeClock.{h,cpp} shipped without a dedicated test suite. Port the six
tests from the monotonic-time branch (test/test_time), retargeted to the
renamed header.

The wrap test crosses 0xFFFFFFFF via advanceTestMillis() rather than a second
setTestMillis(): setTestMillis() sets clockSourceChanged, which makes
getMillis64() rebase its accumulator and swallow the wrap.

* NextHopRouter: fix 49.7-day millis() rollover in retransmission timing

Resolves the "FIXME, handle 51 day rolloever here!!!" in
NextHopRouter::doRetransmissions() by switching the retransmission-due
comparison from plain unsigned <= to a signed-difference cast.

The previous p.nextTxMsec <= now comparison silently breaks across the
~49.7 day millis() wraparound: pending retransmissions either stall
for the remainder of the wrap window, or all fire simultaneously at
the rollover boundary. Long-running router/infrastructure nodes do hit
this in practice.

The replacement (int32_t)(p.nextTxMsec - now) <= 0 is the standard
Arduino/embedded idiom for rollover-safe deadline checks and behaves
identically to the original for any non-wrap timing.

* Address Copilot review: use unsigned half-range for rollover-safe retransmit check

Review feedback from @Copilot on PR #10227: casting a uint32_t
subtraction to int32_t is implementation-defined in C++ when the
unsigned value exceeds INT32_MAX (even though it works on typical
two's-complement targets).

Switch to the fully well-defined unsigned half-range form:
  nextTxMsec is in the past-or-equal iff (now - nextTxMsec) has not
  wrapped past 2^31 ms. Future offsets < 2^31 ms wrap into the top
  half and read as 'not yet'.

Same semantics as the signed-cast version on every two's-complement
platform we care about, but portable to any conforming C++ impl.

* Use monotonic time for airtime windows

* Document monotonic airtime windows

* Fix test_packet_signing sentinel that #10227's rollover fix inverts

test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state parked a
pending packet at nextTxMsec = UINT32_MAX to mean "never retransmit", then
asserted that a rejected repeated packet leaves the retry state untouched.

NextHopRouter::doRetransmissions() now tests whether a retransmit is due with
an unsigned half-range compare, (uint32_t)(now - nextTxMsec) < 0x80000000u,
so that retransmission timing survives the ~49.7 day millis() wrap. Under it
now - 0xFFFFFFFF == now + 1, a small positive delta, so UINT32_MAX reads as
~1ms in the past: the retransmit fires and rewrites nextTxMsec, and the test
failed with "Expected 4294967295 Was 6247".

Use a representable future time instead. Production is unaffected either way -
nextTxMsec is only ever written as millis() + d, and UINT32_MAX came from the
test harness alone - so the sentinel is what needs to go, not the comparison.
Special-casing UINT32_MAX in the retransmit path would keep a value that reads
as "expired" under any wrap-correct compare.

The value is held in a local because millis() advances across
runPipelineIngress(), so recomputing it at the assertion would compare against
a different number.

Reported upstream on meshtastic/firmware#10227, whose branch predates this test.

* Make Throttle time-injectable and add hasElapsed()

Throttle backs ~94 call sites, which makes it the highest-leverage place in
the tree to put the clock seam: reading Time::getMillis() instead of millis()
in its three call sites turns all of them into time-injectable code at once,
without touching any of them. The 32-bit millis() wrap is not otherwise
reachable from a native test.

The read is behaviour-preserving - Time::getMillis() returns millis() unless a
test injects a clock - and the full native suite passes with it live.

Also add hasElapsed(), the complement of isWithinTimespanMs(), because 51 of
the 94 call sites are spelled !isWithinTimespanMs and read poorly. Its
boundary is inclusive (>=) since isWithinTimespanMs uses <; both are
documented. It deliberately does not treat lastExecutionMs == 0 as "never
run": call sites pair that test with the interval check themselves, and
absorbing a sentinel into the one helper every module depends on is exactly
the value-overloading hazard being removed elsewhere.

Migrating the existing !isWithinTimespanMs sites is cosmetic and deliberately
left out of this commit.

test/test_throttle/ covers window semantics, both boundaries, the complement
identity, execute()'s first-run and throttled paths, and - the point of the
exercise - a window opened before the wrap closing correctly after it,
including at the 24h interval that is the longest in the tree.

* Stop disarmed deadline sentinels reaching the comparison

Two deadline variables encoded "inactive" as a magic value that only reads as
"never" because the comparison against it is a naive millis() compare. Under
any rollover-correct comparison both invert to "expired ~49 days ago", so they
have to be untangled before those comparisons can be fixed.

Power::reboot() set rebootAtMsec = -1 on platforms with no reboot
implementation, intending "never fire". Every reader already treats 0 as the
disarm value - powerCommandsCheck() tests `if (rebootAtMsec && ...)`, and
AdminModule writes 0 to cancel - so -1 was both wrong and unnecessary. Use 0.
Left as UINT32_MAX it would reboot-loop the moment the comparison is corrected.

ExternalNotificationModule's nag window compared against nagCycleCutoff, which
holds UINT32_MAX once stopped and 1 at boot. isNagging is the real armed flag,
so test it first and short-circuit: a disarmed cutoff can no longer reach the
arithmetic, while an idle module still takes the same sleep path that the
boot-time value of 1 was relying on.

Note this fixes the sentinel only. The comparison itself is still a naive
`nagCycleCutoff < millis()` and remains on the list to convert.

* Fix millis() rollover in every deadline and interval comparison

Roughly 20 sites compared against millis() directly - `millis() > deadline`,
`deadline < millis()`, `last + interval < millis()`. All of them break for
about 24 days after the 32-bit millis() wrap: depending on which side of the
wrap each value sits, the action either stalls for weeks or fires immediately
and repeatedly. The longest affected interval is the 12 hour NTP renewal, a
~50x margin against the wrap, so none of these needed the range - only the
correct comparison.

Add Throttle::deadlinePassed(deadlineMs) for sites that store an absolute
deadline they cannot re-express as "interval since an event". It uses the same
unsigned half-range test as NextHopRouter::doRetransmissions() rather than
introducing a competing signed-cast idiom, and unlike the signed cast it is
defined for every input. Sites that do store an event use the existing
isWithinTimespanMs / hasElapsed. Nothing gained new state.

Because both helpers read Time::getMillis(), every converted site is now
reachable from a native test that drives the clock across the wrap; the
comparison itself is covered directly in test/test_throttle/.

Sentinel handling is the reason this could not be a mechanical rewrite. The
disarm convention is not uniform: 0 means "inactive" for rebootAtMsec,
shutdownAtMsec, alertBannerUntil, fixHoldEnds, suppressUntilMs and
touchResumeBlockUntilMs; 0 means "due now" for ntp_renew, which is forced to 0
at link-up; UINT32_MAX means "inactive" for nagCycleCutoff; and
alertBannerUntil == 0 in isOverlayBannerShowing() means "show indefinitely".
Every inactive marker is arithmetically far in the past, so a correct
comparison fires on it - each site tests its sentinel before the arithmetic,
and keeps the meaning it had.

Two sites carried a second bug found on the way:

BME680Sensor tested (stateUpdateCounter * STATE_SAVE_PERIOD) < millis(). With
a 6 hour period and a uint16_t counter that product overflows uint32_t after
about 198 saves, independently of the millis() wrap. It now measures the
interval since the last save.

EInkDynamicDisplay had `if (previousRunMs > millis()) return;` as a millis()
overflow guard, which skipped rate limiting entirely for the whole post-wrap
period - the bug it meant to prevent. Every check below it already goes
through Throttle, so the guard is removed rather than fixed.

MotionSensor's calibration countdown is converted to a signed delta rather
than deadlinePassed, because it needs the remaining magnitude and not a
boolean; that matches the already-correct check in the same file.

* Remove getMillis64() and use Throttle for the NodeInfo reply window

getMillis64() had exactly one caller and no callers in tests. It also carried
obligations that made it the wrong shape for this firmware: a wrap accumulator
in mutable statics, which is not ISR-safe, and which must be polled at least
once every ~49.7 days or it silently misses a wrap and returns a time ~49 days
short.

Its one caller only wanted to know whether a 12 hour suppression window had
elapsed - which Throttle answers correctly across the wrap without any
accumulator. NodeInfoModule now stores Time::getMillis() in lastNodeInfoSeen
and tests the window with Throttle::isWithinTimespanMs, so the map holds
milliseconds rather than seconds derived from a 64-bit read.

USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS is user-overridable and now feeds a
multiply by 1000, so a static_assert rejects any value too large to express in
milliseconds instead of letting it wrap.

clockSourceChanged goes too. It existed solely to rebase getMillis64()'s
accumulator when a test swapped clock sources, and it made the wrap untestable
through the injection API: setTestMillis() set the flag, so a wrap crossed by
two setTestMillis() calls was swallowed. With the accumulator gone the flag has
nothing to rebase, and the injection API is a plain settable clock.

The three getMillis64 tests are dropped as they no longer describe anything.
One test replaces them, pinning that advanceTestMillis() wraps past
0xFFFFFFFF rather than saturating, since the Throttle wrap tests rely on it.

Also fix eviction in pruneLastNodeInfoCache(): it picked the entry with the
smallest stored stamp, which is the wrong victim once some stamps sit on the
far side of the wrap. It now evicts the largest elapsed time.

* Add CI guard and docs rule against naive millis() comparisons

Fixing the existing sites does not stop the next one being added. The
millis-deadline-check job rejects millis() placed directly next to a comparison
operator, in either order, anywhere in src/. It lives in test_native.yml
alongside suite-count-check, which sets the precedent for a repo-hygiene guard
that CI enforces and bin/run-tests.sh does not.

The correct idioms all subtract before comparing, so none of them match the
pattern. Line comments are stripped first, so documentation is free to name the
broken form - as the guard's own comment and the coding conventions both do.

Writing the check before finishing the sweep turned out to be worth it: it
found roughly 14 sites that a by-hand audit of deadline variables had missed,
including two extra nagCycleCutoff compares, both boot-screen timeouts, and a
6 hour sensor save interval that was also overflowing a uint32_t multiply.

.github/millis-deadline-allowlist.txt covers the cases that are genuinely not
deadline tests. Both current entries are uptime thresholds - "has the device
been up N ms" - with no stored deadline and no event to measure from: a 30s
button holdoff against phantom shutdown from floating pins, and a 10s window
for the OEM boot logo. Each re-crosses its threshold once per wrap, which is
harmless for boot-holdoff logic and not worth new state to avoid. Entries are
keyed on file plus exact source text, without line numbers, so an edit above an
entry does not silently invalidate it.

Locally the guard reports 19 matches before the sweep and 2 after, both
allowlisted.

The Throttle bullet in the coding conventions is rewritten from "prefer
Throttle for rate limiting" to "never compare against millis() directly", lists
all four helpers with when to use which, names the CI guard, and documents the
sentinel hazard with the rebootAtMsec = -1 case that would have become a reboot
loop. Mirrored into AGENTS.md; CLAUDE.md gets a pointer row.

* Trim rollover comments to what the code needs

The comments added with the millis() rollover fixes carried too much of the
investigation that produced them: how many sites were found, which document
recorded them, what the old code used to do. That belongs in the commit history,
not in the source, and some of it was already stale - Power::reboot() still
described the check it disarms as "a naive millis() > deadline" when that
comparison had been fixed in the same series.

What stays is the non-obvious part at each site: which sentinel value the
variable overloads and what it means there, since that differs between call
sites and is what a correct comparison gets wrong. 0 means "not scheduled" for
rebootAtMsec, "renew now" for ntp_renew, and "show indefinitely" in
isOverlayBannerShowing().

Exposition is kept where it earns its place: the Throttle helpers, the uptime
clock's note on why there is no 64-bit variant, and the tests. The Throttle
docs lose only the site count and the "longest interval in the firmware"
statistic, both of which would age badly; the range trade-off between the two
forms is what a caller actually needs.

Comments only - no code changed, verified by diff.

* possible fixes

* Address review feedback on the rollover fixes

- BME680Sensor: checkpoint lastStateSaveMs after a successful write instead of
  at the interval test. The first save (IAQ accuracy >= 2) left it at 0, timing
  the next save from boot, and stamping before the write deferred the retry a
  full period when the write failed. Reads Time::getMillis(), the same clock
  Throttle compares against.

- Throttle: add deadlinePassedAt(now, deadline) for loops that snapshot the
  clock once and test many deadlines; deadlinePassed() now delegates to it.
  NextHopRouter::doRetransmissions() uses it, replacing the inline half-range
  compare adopted from #10227 (nightjoker7) - same arithmetic, credited at the
  call site - and takes its snapshot from Time::getMillis() so setNextTx()
  deadlines and the due test cannot diverge under an injected test clock.

- test_native.yml: set -euo pipefail in the millis-deadline guard, matching the
  sibling suite-count job. Without -e a partially failed scan could report "no
  violations" from truncated output.

- test_packet_signing: build the not-due deadline from Time::getMillis() rather
  than millis(), so the test and the router read one clock.

- test_throttle: cover deadlinePassedAt(), and correct a wrapped-value comment
  (0xFFFFFF00 + 400 is 0x00000090, not 0x00000094).

Two review comments were declined: the AirTime mutex (every airTime-> caller
runs in the single cooperative loop, WebServerThread included) and the
MotionSensor 0-sentinel countdown (the calibration frame is only installed
while a window is open).

clod helped out here

* Correct the described failure window of a naive millis() compare

The comments and agent docs said a bare `millis() > deadline` "breaks for ~24
days after the wrap". That figure belongs to the fix, not the bug: it is the
half-range limit of deadlinePassed(), which reads deadlines more than 2^31 ms
ahead as already passed, and the range over which a UINT32_MAX sentinel reads
as passed.

The naive compare's actual failure is an inversion lasting only while the
deadline sits on the far side of the wrap, so it is bounded by the interval:
the action fires immediately and loses its wait, or blocks for about the wait
it should have performed - days for the nRF52 flash-corruption backoff,
one skipped cycle for a seconds-long retransmit timer.

Comments and docs only; the ~24.8 day statements that correctly describe
deadlinePassed()'s own range are left as they were.

clod helped out here

* Restore a monotonic uptime clock and consolidate the wrap counters

Time::getMillisMonotonic() is the getMillis64() shape - a 32-bit wrap
counter carried across reads - promoted to the shared timebase, with
Time::getUptimeSecs() as the derived whole-seconds view. This deliberately
reverses the earlier removal of getMillis64(), and the distinction matters:
removal was right for a lazily-read accumulator with one rare caller, where
a 49.7-day gap between reads silently swallowed a wrap. Here every read is
the poll and AirTime::runOnce() guarantees one per second; the missed-wrap
contract is pinned by a test rather than left as a footnote.

Three private wrap counters collapse into it:

- AirTime::syncNow() takes its seconds from Time::getUptimeSecs() and drops
  its lastSyncMsec checkpoint; window rotation is unchanged.
- DeviceTelemetryModule loses refreshUptime()/uptimeWrapCount/uptimeLastMs;
  uptime_seconds comes from Time::getUptimeSecs(), which also removes the
  0.296s-per-wrap truncation of (0xFFFFFFFF / 1000) * wraps. Its two
  interval checks move to Throttle::hasElapsed().
- HostMetricsModule's copies of those members were never read (its uptime
  comes from /proc/uptime) - deleted.

Not ISR-safe (unguarded mutable carry): ISRs keep using getMillis(), which
stays a pure read. Audited: no interrupt-context file reads getTime(),
getValidTime(), or the new accessors.

test/native-suite-count 44 -> 45: the bump for test_uptime_clock was lost
in a branch history rewrite, leaving every later value off by one -
run-tests.sh reports AMBER and CI's suite-count-check fails on the current
push until this correction.

* Anchor the wall clock in monotonic milliseconds

getTime() computed elapsed-since-time-set as a 32-bit millis() delta, so a
node that took time once and stayed up past 49.7 days reported a wall clock
one full cycle in the past - and last_heard, rx_time, message and position
stamps all inherited it. The anchor is now the 64-bit monotonic count
(timeStartMsec -> timeStartMs64) and the elapsed term is computed in 64-bit,
so the wall clock is exact at any uptime.

All six anchor writers follow: the five hardware-RTC read branches and
perhapsSetRTC(), which keeps a truncated 32-bit copy of the same instant for
its Throttle-checked rate-limit stamps. The test seams anchor the same way.

Two native regression tests drive getTime() across the wrap through the
Time seam - one anchored before the wrap and read after it, one anchored
after a counted wrap - with the test epoch derived from BUILD_EPOCH so the
plausibility window cannot rot as the build date advances.

* Stamp the rx_time placeholder in monotonic uptime seconds

computeRxTimeStamp() stamped Time::getMillis() when the clock was untrusted,
and reconcilePendingRxTimes() back-calculated with a 32-bit millis() delta -
correct within one wrap, but a placeholder older than 49.7 days aliased to a
small elapsed value and reconciled to a plausible-but-wrong recent epoch:
the exact failure has_rx_time exists to prevent, reachable by an ordinary
unattended router whose phone connects two months in.

The placeholder is now Time::getUptimeSecs(). Both stamps come off the
monotonic counter, so the elapsed term is exact at any age and the aliasing
window is gone outright rather than widened. If elapsed somehow exceeds the
epoch itself, the packet stays un-dated (absent, never wrong) instead of
clamping to a pre-1970 value. Defence in depth: a placeholder that leaks
needs ~50 years of uptime to cross MIN_PLAUSIBLE_EPOCH, where milliseconds
took 18.3 days.

The stream-API reconciliation tests keep their scenarios with the placeholder
unit switched, and ScopedTimeFixture resets the monotonic carry so uptime
seconds are deterministic per case.

* Date nodes heard before the clock arrives, without polluting last_heard

A node first heard while the wall clock was untrusted got no last_heard at
all, and nothing backfilled it once time arrived - the phone showed "Last
heard: unknown" for a node it had just announced. The arrival instant now
waits in a RAM-only sidecar (NodeNum -> uptime seconds, 32 slots,
reuse-oldest - the RouteHealth shape) and is converted to a real epoch on
the clock-becoming-trusted transition, beside the existing rx_time
reconciliation. last_heard itself never holds anything but a real epoch or
0: it persists to flash and the warm tier, where an uptime-relative value
would be meaningless after reboot.

The sidecar's write sites are updateFrom()'s no-trusted-clock path (the
rx_time placeholder already carries the arrival instant, so this is a store,
not a second clock read) and addFromContact's anti-eviction stamps, which
previously wrote a bare getTime() - boot-relative seconds on a clockless
node, the exact value lastHeardIsWallClock() exists to catch. Eviction
ranking honours the stamps: heard-this-boot outranks every stored epoch,
ordered among themselves, so a stamped contact is not the first victim.

PhoneAPI re-reads last_heard at nodeinfo send time: a record prefetched
before the clock became trusted can carry 0 while the store has since been
backfilled, and re-reading at the pop makes handshake ordering (time-set vs
node-list download) irrelevant. Backfill never moves last_heard backwards
and skips the pathological elapsed-exceeds-epoch case. A node evicted to
the warm tier before time arrives is still absorbed with last_heard 0 -
same as before, bounded to the untrusted window.

* Update the agent docs for the monotonic timebase

The conventions bullet asserted there is deliberately no 64-bit millis; the
monotonic uptime clock restored for timestamps changes that contract. State
the split explicitly: Throttle for deadlines and intervals (no carry state),
Time::getMillisMonotonic()/getUptimeSecs() for timestamps, polled by
construction and not ISR-safe.

* Publish the monotonic wrap carry from a single writer

getMillisMonotonic() was a read-modify-write on two unguarded statics, and it
is reached off the main loop: the nRF52 Bluefruit task via
onFromRadioAuthorize() -> PhoneAPI::getFromRadio -> getValidTime(), and the
portduino civetweb workers via the same path. Two readers interleaving inside
the wrap window could each increment the carry, putting every uptime and
wall-clock reading 2^32 ms ahead for the rest of the boot - a permanent ~49.7
day jump in rx_time, last_heard and ClientNotification.time.

Readers no longer write. serviceMonotonic() publishes a snapshot behind a
seqlock and is the only writer; a reader adds its own unsigned elapsed time to
that snapshot, which is exact across the wrap, so it never inspects the
boundary and cannot miscount it. The main loop publishes every iteration, so
the once-per-49.7-days obligation now has the whole window of margin instead of
resting on an instruction-wide race.

AirTime was the guaranteed poller and is now a pure reader, so the two airtime
wrap tests step the clock the way loop() does. The test clock itself is atomic
so a suite can drive it from one thread while others read.

* Re-arm the GPS ephemeris hold when none is in force

The rollover sweep guarded the hold re-arm with `fixHoldEnds != 0 &&`, which
reads like the sentinel rule but inverts this site. The comparison it replaced,
`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, was always true when nothing
was armed - that was the point, since 0 means "not holding" and so is a reason
to arm. With the guard, a publish that cleared the hold without sleeping (the
`shouldPublish && !tooLong && !holdExpired` path, which does not call down())
left hasValidLocation set and prev_fixQual non-zero, so no disjunct held:
nothing re-armed, nothing published, and the receiver stayed powered at the
200ms poll until searchedTooLong() fired.

State the question positively instead. fixHoldInForce() is the only place the
sentinel is interpreted, and both of runOnce()'s decisions derive from it - the
asymmetry is now visible rather than implied, since arming does not require a
prior hold but expiring does. Its `!= 0` test is not redundant with the
arithmetic: deadlinePassed() is an unsigned half-range test, so past 2^31 ms of
uptime the sentinel reads as a deadline ~24.9 days in the future.

Kept beside its caller rather than in a header; the native test build compiles
GPS.cpp, so the suite declares the prototypes.

Also converts the getACK() wait to isWithinTimespanMs(start, interval): it has
both the start instant and the interval in hand, which gives the full 49.7-day
range instead of 24.8 days ahead, and takes its anchor from Time::getMillis()
so the wait is injectable.

* Date the NodeInfo reply window in uptime seconds

The 12h reply-suppression stamp regressed from wrap-immune 64-bit seconds to
raw 32-bit milliseconds, and pruneLastNodeInfoCache() evicts only by node count
and DB membership - never by age. A stable mesh under the node cap therefore
keeps every stamp indefinitely, and once uptime passes 49.7 days an old one
aliases back into the window: `now - stamp` computes as ~0 and a legitimate
NodeInfo request goes unanswered for up to 12h. It self-heals and repeats once
per wrap cycle.

Store Time::getUptimeSecs() instead, which does not wrap for 136 years, and
drop the millisecond conversion the previous shape needed. Entries past the
window are now evicted too: they can only ever decide "don't suppress".

N8-N11 cover the window from both sides, and N10 pins the regression - it needs
a full 2^32 ms of uptime to elapse, not merely a crossing of the boundary,
because that is when a millisecond stamp reads as "answered this instant".

tearDown() now restores the injected clock and C14's region and TX bucket. A
failing assertion aborts the test body, so restoring at the end of it leaked
that state into every later case.

* Update the agent docs for the single-writer clock and sentinel direction

Two rules the preceding three commits changed.

The monotonic clock is no longer maintained by whoever happens to read it:
serviceMonotonic() is the only writer, readers are pure, and calling it from
anywhere but the main loop reintroduces the double-count.

The sentinel guidance gained the half it was missing. It named UINT32_MAX as a
sentinel while prescribing an idiom that only covers 0, and it assumed the
sentinel always means "suppress" - at the GPS fix-hold site it meant "fire",
which is how that regression passed review looking like the rule.

* Name the fix-hold expiry predicate and arm it from the injected clock

holdJustExpired() gives the second reading of the fixHoldEnds sentinel a
name beside the first, so both are pinned by test/test_gps_fix_hold/ and
neither can be respelled at the call site. The old inline form could not
be tested: written as a literal, its guard folds at compile time and the
assertion asserts nothing.

The arm site used bare millis() while the evaluation reads the Throttle
clock; same value in production, but it kept that write out of reach of
Time::setTestMillis(). Remap a deadline that lands on 0, which would
otherwise read as no hold at all.

* Share the extend formula between the clock's reader and writer

getMillisMonotonic() and serviceMonotonic() carried byte-identical wrap
arithmetic. A one-sided edit to either would drift the published carry
from what readers report, so keep one copy.

* Trim the NodeInfo dedup comment to the house limit

* todo note for potential future imrpovments

* fix some simple deadlines

* Trim the hold-expiry test comment to the house limit

* Fix non-blocking uptime publication and pre-clock recency edges (#29)

* fix(time): avoid blocking monotonic readers

* test(time): make paused-publisher check deterministic

* fix(time): address review portability gaps

* Init the eviction sentinel to the newest possible recency

EvictionRecency{} is {0, false}, which evictionRecencyOlder() ranks as older than
every candidate: without the oldestIndex/oldestBoringIndex guards nothing would
ever be selected and a full node DB would stop evicting entirely.

Init to the genuine maximum instead, so the sentinel is correct on its own. The
index guards stay: two independent reasons the scan is right beats one.

* Keep the deadline-guard check name branch protection matches

The guard was widened to cover Time::getMillis() and unqualified getMillis(),
and renamed to suit. Upstream branch protection matches required checks by name,
so a rename means the old name never reports and merges block on a check that
will never arrive.

Widen the guard, keep the name; the descriptive text carries the broader scope.

* Correct native-suite-count to 47 after the develop merge

Upstream #11293 added test_nmea_wpl and took develop's count to 43; this branch
had independently reached 46. Merging develop resolved the counter textually,
keeping 46, while the directory set became the union of both sides at 47.

The suite-count CI gate fails on the mismatch, and it gates the native test jobs,
so the tests themselves were being skipped.

* test(uptime): make the wrap fall where the comment says it does

The concurrent-reader case started at 0xFFFFF000, leaving 0x1000 to the wrap, so
the 0x800 advance annotated "cross the wrap" fell short and the wrap actually
happened during the following 60s advance.

Start at 0xFFFFF800 instead, so the first advance lands exactly on the wrap while
the readers are running and the second is the ordinary time after it - the shape
both comments already described. Total elapsed is unchanged, so the closing
assertion still holds.

* Respond to human comments

* Did I ever tell you about the time I went to Shelbyville? I wore an onion on my belt, which was the style at the time.

* Convert the I2S nag deadline develop dragged in

The HAS_I2S_SPEAKER_NRF52 RTTTL block arrived from develop with a raw
nagCycleCutoff >= millis(), which the deadline guard rejects. Use the same
Throttle::deadlinePassed() form as the two sibling paths in this function.

* Arm the LittleFS format guard with a flag, not a zero timestamp

preFSBegin() runs in the first millisecond of boot, so millis() can legitimately
return 0 there. Both readers of last_format_ms treated 0 as "nothing formatted
this boot", which would skip the repeat-corruption escalation and let a dead
flash reformat-loop instead of reporting FLASH_CORRUPTION_UNRECOVERABLE.

* Note the single-thread contract on AirTime

* Note the AirTime locking TODO, and tighten the thread note

The two constant getters are not constrained, and getSilentMinutes() reads the
buckets without rotating them, so "the accessors mutate" was not accurate.

* trunk: ignore trufflehog false positives on millis-wrap test constants

test_throttle and test_uptime_clock pin dense clusters of hex boundary
constants (0xFFFFFF00u and neighbors) to exercise 32-bit millis()
rollover. trufflehog's Lob detector stitches nearby hex literals into
one candidate string, and the result happens to match a Lob API key
shape - not a secret, just test fixtures.

Same pattern already used for the gitleaks/nodedb-fixture false
positive in this file.

---------

Co-authored-by: nightjoker7 <mattdeering7@gmail.com>
Co-authored-by: Clive Blackledge <clive@ansible.org>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Tom
2026-08-12 16:49:17 -05:00
committed by GitHub
co-authored by nightjoker7 Clive Blackledge Benjamin Faershtein Thomas Göttgens Ben Meadors
parent db3eb91015
commit fdb644e0b7
53 changed files with 2006 additions and 274 deletions
+7 -5
View File
@@ -837,12 +837,13 @@ bool Power::setup()
void Power::powerCommandsCheck()
{
if (rebootAtMsec && millis() > rebootAtMsec) {
// 0 means "not scheduled" for both, and reads as long expired - test it first.
if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) {
LOG_INFO("Rebooting");
reboot();
}
if (shutdownAtMsec && millis() > shutdownAtMsec) {
if (shutdownAtMsec && Throttle::deadlinePassed(shutdownAtMsec)) {
shutdownAtMsec = 0;
shutdown();
}
@@ -884,9 +885,10 @@ void Power::reboot()
#elif defined(ARCH_STM32)
HAL_NVIC_SystemReset();
#else
rebootAtMsec = -1;
LOG_WARN("FIXME implement reboot for this platform; some settings "
"need restart to apply");
// 0 disarms; UINT32_MAX would read as long expired and reboot-loop.
rebootAtMsec = 0;
LOG_WARN("FIXME implement reboot for this platform. Note that some settings "
"require a restart to be applied");
#endif
}
+4 -3
View File
@@ -5,6 +5,7 @@
#include "concurrency/OSThread.h"
#include "configuration.h"
#include "main.h"
#include "mesh/Throttle.h"
namespace concurrency
{
@@ -29,9 +30,9 @@ class PowerFSMThread : public OSThread
if (powerStatus->getHasUSB()) {
timeLastPowered = millis();
} else if (config.power.on_battery_shutdown_after_secs > 0 && config.power.on_battery_shutdown_after_secs != UINT32_MAX &&
millis() > (timeLastPowered +
Default::getConfiguredOrDefaultMs(
config.power.on_battery_shutdown_after_secs))) { // shutdown after 30 minutes unpowered
Throttle::hasElapsed(
timeLastPowered,
Default::getConfiguredOrDefaultMs(config.power.on_battery_shutdown_after_secs))) { // unpowered too long
powerFSM.trigger(EVENT_SHUTDOWN);
}
+82 -17
View File
@@ -1,33 +1,98 @@
// See UptimeClock.h for the full contract.
#include "UptimeClock.h"
#include <Arduino.h>
#include <atomic>
uint32_t Time::getMillis()
{
#ifdef PIO_UNIT_TESTING
if (Time::useTestClock)
return Time::testNowMs;
if (Time::useTestClock.load(std::memory_order_relaxed))
return Time::testNowMs.load(std::memory_order_relaxed);
#endif
return millis();
}
uint64_t Time::getMillis64()
namespace
{
static uint32_t lastLow = 0; // last 32-bit sample
static uint32_t highWord = 0; // number of observed wraps
struct PublishedSnapshot {
std::atomic<uint32_t> high{0};
std::atomic<uint32_t> low{0};
};
uint32_t now = Time::getMillis();
// The constexpr atomic initializers make both snapshots available before firmware startup.
PublishedSnapshot published[2];
std::atomic<uint32_t> publishedGeneration{0};
#ifdef PIO_UNIT_TESTING
// A test swapping clock sources (real <-> injected) can make `now` jump backward for
// reasons other than a genuine wrap - rebase rather than miscount it as one.
if (Time::clockSourceChanged) {
lastLow = now;
highWord = 0;
Time::clockSourceChanged = false;
}
std::atomic<Time::MonotonicPublishHook> monotonicPublishHook{nullptr};
#endif
if (now < lastLow)
highWord++; // low word wrapped since last call
lastLow = now;
return (static_cast<uint64_t>(highWord) << 32) | now;
// Extend a published (high, low) snapshot to `now`; unsigned subtraction is exact across the wrap
// for any gap under 49.7 days. One copy, because reader and writer must agree on it exactly.
uint64_t extendPublished(uint32_t high, uint32_t low, uint32_t now)
{
return ((((uint64_t)high << 32) | low) + (uint32_t)(now - low));
}
// A generation change means the writer completed a publish while this copy was being read. A
// paused publish leaves the generation unchanged and writes only the inactive snapshot.
void readPublished(uint32_t &high, uint32_t &low)
{
for (;;) {
const uint32_t before = publishedGeneration.load(std::memory_order_acquire);
PublishedSnapshot &snapshot = published[before & 1u];
high = snapshot.high.load(std::memory_order_relaxed);
low = snapshot.low.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (publishedGeneration.load(std::memory_order_relaxed) == before)
return;
}
}
} // namespace
uint64_t Time::getMillisMonotonic()
{
uint32_t high, low;
readPublished(high, low);
// The reader writes nothing back; it just extends the last published carry to now.
return extendPublished(high, low, getMillis());
}
uint32_t Time::getUptimeSecs()
{
return (uint32_t)(getMillisMonotonic() / 1000);
}
void Time::serviceMonotonic()
{
const uint32_t generation = publishedGeneration.load(std::memory_order_relaxed);
PublishedSnapshot &active = published[generation & 1u];
const uint32_t low = active.low.load(std::memory_order_relaxed);
const uint32_t high = active.high.load(std::memory_order_relaxed);
const uint64_t next = extendPublished(high, low, getMillis());
PublishedSnapshot &inactive = published[(generation + 1u) & 1u];
inactive.high.store((uint32_t)(next >> 32), std::memory_order_relaxed);
inactive.low.store((uint32_t)next, std::memory_order_relaxed);
#ifdef PIO_UNIT_TESTING
if (const auto hook = monotonicPublishHook.load(std::memory_order_relaxed))
hook();
#endif
publishedGeneration.store(generation + 1u, std::memory_order_release);
}
#ifdef PIO_UNIT_TESTING
void Time::resetMonotonicForTests()
{
publishedGeneration.store(0, std::memory_order_relaxed);
for (auto &snapshot : published) {
snapshot.high.store(0, std::memory_order_relaxed);
snapshot.low.store(0, std::memory_order_relaxed);
}
monotonicPublishHook.store(nullptr, std::memory_order_relaxed);
}
void Time::setMonotonicPublishHookForTests(MonotonicPublishHook hook)
{
monotonicPublishHook.store(hook, std::memory_order_relaxed);
}
#endif
+43 -20
View File
@@ -1,46 +1,69 @@
#pragma once
#include <cstdint>
#ifdef PIO_UNIT_TESTING
#include <atomic>
#endif
// Monotonic uptime clock, injectable so tests can drive a virtual timebase instead of sleeping.
// Uptime only; see gps/RTC.h for wall-clock. Not named Time.h: -Isrc would shadow C's <time.h>.
namespace Time
{
#ifdef PIO_UNIT_TESTING
// Test-only virtual clock; OFF by default so suites relying on real time are unaffected.
inline uint32_t testNowMs = 0;
inline bool useTestClock = false;
inline bool clockSourceChanged = true; // forces getMillis64() to rebase its wrap accumulator
// Test-only virtual clock; OFF by default so suites relying on real time are unaffected. Atomic so
// a suite can step the clock from one thread while others read it - the concurrent-reader cases in
// test_uptime_clock/ do exactly that.
inline std::atomic<uint32_t> testNowMs{0};
inline std::atomic<bool> useTestClock{false};
using MonotonicPublishHook = void (*)();
inline void setTestMillis(uint32_t ms)
{
testNowMs = ms;
useTestClock = true;
clockSourceChanged = true;
testNowMs.store(ms, std::memory_order_relaxed);
useTestClock.store(true, std::memory_order_relaxed);
}
inline void advanceTestMillis(uint32_t deltaMs)
{
// Advancing from 0 after getMillis64() sampled the real clock steps backward, which would
// otherwise be miscounted as a wrap.
if (!useTestClock)
clockSourceChanged = true;
testNowMs += deltaMs;
useTestClock = true;
testNowMs.fetch_add(deltaMs, std::memory_order_relaxed);
useTestClock.store(true, std::memory_order_relaxed);
}
// Restore real-clock behaviour (call in test tearDown if a suite mixes real and fake time).
inline void useRealClock()
{
useTestClock = false;
testNowMs = 0;
clockSourceChanged = true;
useTestClock.store(false, std::memory_order_relaxed);
testNowMs.store(0, std::memory_order_relaxed);
}
// Zero the published wrap carry. Suites that assert absolute uptime values call this in setUp():
// a previous case that moved the test clock backwards left a counted wrap behind.
void resetMonotonicForTests();
void setMonotonicPublishHookForTests(MonotonicPublishHook hook);
#endif
/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis().
/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). For "has this interval
/// elapsed / deadline arrived" use Throttle (isWithinTimespanMs / hasElapsed / deadlinePassed),
/// which is wrap-correct with no carry state at all.
uint32_t getMillis();
/// Milliseconds since boot, 64-bit, rollover-immune. Must be polled at least once per ~49.7-day
/// wrap window to catch every wrap, and keeps mutable static carry state, so it is NOT ISR-safe.
uint64_t getMillis64();
/// Milliseconds since boot as a monotonic 64-bit count.
///
/// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic()
/// plus the unsigned elapsed time since that snapshot, which is exact across the wrap. A reader
/// that preempts publication uses the previous snapshot. If publication completes during a copy,
/// the reader retries; it never waits for a publish in progress.
///
/// Not intended for ISR call sites because lock-free std::atomic operations are not guaranteed by
/// every supported toolchain. ISRs use getMillis(); the publication protocol itself never waits.
uint64_t getMillisMonotonic();
/// Whole seconds since boot, derived from getMillisMonotonic() (~136 years of range). This is
/// the unit to store when an instant must be dated before the wall clock is trustworthy.
uint32_t getUptimeSecs();
/// Advances the published wrap carry. THE ONLY WRITER - call it from the main loop and nowhere
/// else. Two concurrent callers could count one wrap twice, jumping every uptime and wall-clock
/// reading ~49.7 days forward for the rest of the boot.
///
/// Must run at least once per ~49.7-day wrap window; the main loop calls it every iteration.
void serviceMonotonic();
} // namespace Time
+99 -65
View File
@@ -1,6 +1,8 @@
#include "airtime.h"
#include "NodeDB.h"
#include "UptimeClock.h"
#include "configuration.h"
#include <string.h>
AirTime *airTime = NULL;
@@ -11,6 +13,9 @@ uint32_t air_period_rx[PERIODS_TO_LOG];
void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms)
{
// A packet may be logged immediately after waking from light sleep. Sync first so
// the packet is counted in the current wall-time bucket, not a stale awake-time bucket.
syncNow();
if (reportType == TX_LOG) {
LOG_DEBUG("Packet TX: %ums", airtime_ms);
@@ -33,47 +38,112 @@ void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms)
uint8_t AirTime::currentPeriodIndex()
{
return ((getSecondsSinceBoot() / SECONDS_PER_PERIOD) % PERIODS_TO_LOG);
return ((secSinceBoot / SECONDS_PER_PERIOD) % PERIODS_TO_LOG);
}
uint8_t AirTime::getPeriodUtilMinute()
{
return (getSecondsSinceBoot() / 10) % CHANNEL_UTILIZATION_PERIODS;
return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS;
}
uint8_t AirTime::getPeriodUtilHour()
{
return (getSecondsSinceBoot() / 60) % MINUTES_IN_HOUR;
return (secSinceBoot / 60) % MINUTES_IN_HOUR;
}
void AirTime::airtimeRotatePeriod()
{
// Preserve the public helper while keeping all rotation logic in one monotonic-time path.
syncNow();
}
if (this->airtimes.lastPeriodIndex != this->currentPeriodIndex()) {
LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex());
void AirTime::syncNow()
{
// Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move
// airtime accounting. Pure read; the main loop publishes the wrap carry it derives from.
uint32_t nowSecs = Time::getUptimeSecs();
for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) {
this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i];
this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i];
this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i];
air_period_tx[i + 1] = this->airtimes.periodTX[i];
air_period_rx[i + 1] = this->airtimes.periodRX[i];
}
this->airtimes.periodTX[0] = 0;
this->airtimes.periodRX[0] = 0;
this->airtimes.periodRX_ALL[0] = 0;
air_period_tx[0] = 0;
air_period_rx[0] = 0;
if (firstTime) {
memset(this->utilizationTX, 0, sizeof(this->utilizationTX));
memset(this->channelUtilization, 0, sizeof(this->channelUtilization));
memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX));
memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX));
memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL));
memset(air_period_tx, 0, sizeof(air_period_tx));
memset(air_period_rx, 0, sizeof(air_period_rx));
this->secSinceBoot = nowSecs;
this->lastUtilPeriod = this->getPeriodUtilMinute();
this->lastUtilPeriodTX = this->getPeriodUtilHour();
this->airtimes.lastPeriodIndex = this->currentPeriodIndex();
firstTime = false;
return;
}
if (nowSecs == this->secSinceBoot) {
return;
}
uint32_t oldSecSinceBoot = this->secSinceBoot;
this->secSinceBoot = nowSecs;
// Historical airtime reports use 1-hour buckets. If multiple hours elapsed while
// asleep, rotate each crossed bucket or clear the whole report window.
uint32_t elapsedAirtimePeriods = (this->secSinceBoot / SECONDS_PER_PERIOD) - (oldSecSinceBoot / SECONDS_PER_PERIOD);
if (elapsedAirtimePeriods >= PERIODS_TO_LOG) {
memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX));
memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX));
memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL));
memset(air_period_tx, 0, sizeof(air_period_tx));
memset(air_period_rx, 0, sizeof(air_period_rx));
} else {
while (elapsedAirtimePeriods-- > 0) {
LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex());
for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) {
this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i];
this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i];
this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i];
air_period_tx[i + 1] = this->airtimes.periodTX[i];
air_period_rx[i + 1] = this->airtimes.periodRX[i];
}
this->airtimes.periodTX[0] = 0;
this->airtimes.periodRX[0] = 0;
this->airtimes.periodRX_ALL[0] = 0;
air_period_tx[0] = 0;
air_period_rx[0] = 0;
}
}
this->airtimes.lastPeriodIndex = this->currentPeriodIndex();
// Channel utilization is a rolling 60-second view split into six 10-second buckets.
// Clear every bucket crossed while asleep so old airtime decays by real elapsed time.
uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10);
if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) {
memset(this->channelUtilization, 0, sizeof(this->channelUtilization));
} else {
for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) {
this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
}
}
this->lastUtilPeriod = this->getPeriodUtilMinute();
// TX utilization is a rolling 60-minute view used by duty-cycle checks.
uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60);
if (elapsedUtilTXPeriods >= MINUTES_IN_HOUR) {
memset(this->utilizationTX, 0, sizeof(this->utilizationTX));
} else {
for (uint32_t i = 1; i <= elapsedUtilTXPeriods; i++) {
this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0;
}
}
this->lastUtilPeriodTX = this->getPeriodUtilHour();
}
uint32_t *AirTime::airtimeReport(reportTypes reportType)
{
// Reports may be requested before runOnce() executes after wake.
syncNow();
if (reportType == TX_LOG) {
return this->airtimes.periodTX;
@@ -97,11 +167,16 @@ uint32_t AirTime::getSecondsPerPeriod()
uint32_t AirTime::getSecondsSinceBoot()
{
// Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets.
syncNow();
return this->secSinceBoot;
}
float AirTime::channelUtilizationPercent()
{
// Gate decisions should see buckets that have decayed across light-sleep time.
syncNow();
uint32_t sum = 0;
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
sum += this->channelUtilization[i];
@@ -112,6 +187,9 @@ float AirTime::channelUtilizationPercent()
float AirTime::utilizationTXPercent()
{
// Duty-cycle checks use this value, so keep it current even outside the periodic thread.
syncNow();
uint32_t sum = 0;
for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) {
sum += this->utilizationTX[i];
@@ -162,50 +240,6 @@ AirTime::AirTime() : concurrency::OSThread("AirTime"), airtimes({}) {}
int32_t AirTime::runOnce()
{
secSinceBoot++;
uint8_t utilPeriod = this->getPeriodUtilMinute();
uint8_t utilPeriodTX = this->getPeriodUtilHour();
if (firstTime) {
// Init utilizationTX window to all 0
for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) {
this->utilizationTX[i] = 0;
}
// Init channelUtilization window to all 0
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
this->channelUtilization[i] = 0;
}
// Init airtime windows to all 0
for (int i = 0; i < PERIODS_TO_LOG; i++) {
this->airtimes.periodTX[i] = 0;
this->airtimes.periodRX[i] = 0;
this->airtimes.periodRX_ALL[i] = 0;
// air_period_tx[i] = 0;
// air_period_rx[i] = 0;
}
firstTime = false;
lastUtilPeriod = utilPeriod;
} else {
this->airtimeRotatePeriod();
// Reset the channelUtilization window when we roll over
if (lastUtilPeriod != utilPeriod) {
lastUtilPeriod = utilPeriod;
this->channelUtilization[utilPeriod] = 0;
}
if (lastUtilPeriodTX != utilPeriodTX) {
lastUtilPeriodTX = utilPeriodTX;
this->utilizationTX[utilPeriodTX] = 0;
}
}
syncNow();
return (1000 * 1);
}
+10
View File
@@ -39,6 +39,12 @@ void logAirtime(reportTypes reportType, uint32_t airtime_ms);
uint32_t *airtimeReport(reportTypes reportType);
// Not thread-safe: everything but getPeriodsToLog()/getSecondsPerPeriod() either rotates the
// windows via syncNow() or reads the buckets. Current callers are all on the OSThread scheduler -
// RadioLibInterface/SimRadio, RadioInterface, Router, DeviceTelemetry, ContentHandler, and the
// screen renderers. New callers must be on that thread too, or this needs a lock.
// TODO: airtime lock-guarding - serialise the above behind a lock so the contract is enforced
// rather than documented. Kept out of this PR: it is a separate concern from millis() rollover.
class AirTime : private concurrency::OSThread
{
@@ -66,6 +72,8 @@ class AirTime : private concurrency::OSThread
bool firstTime = true;
uint8_t lastUtilPeriod = 0;
uint8_t lastUtilPeriodTX = 0;
// Time::getUptimeSecs() as of the last syncNow(); the gap since is what the windows rotate by,
// so they stay correct even if the scheduler was paused by light sleep.
uint32_t secSinceBoot = 0;
uint8_t max_channel_util_percent = 40;
uint8_t polite_channel_util_percent = 25;
@@ -81,6 +89,8 @@ class AirTime : private concurrency::OSThread
uint8_t getPeriodUtilMinute();
uint8_t getPeriodUtilHour();
uint8_t currentPeriodIndex();
// Advance rolling airtime windows from monotonic uptime, not from runOnce() calls.
void syncNow();
protected:
virtual int32_t runOnce() override;
+34 -6
View File
@@ -10,6 +10,7 @@
#include "NodeDB.h"
#include "PowerMon.h"
#include "Throttle.h"
#include "UptimeClock.h"
#include "buzz.h"
#include "concurrency/Periodic.h"
#include "gps/RTC.h"
@@ -350,11 +351,13 @@ GPS_RESPONSE GPS::getACK(const char *message, uint32_t waitMillis)
uint8_t buffer[768] = {0};
uint8_t b;
int bytesRead = 0;
uint32_t startTimeout = millis() + waitMillis;
// Start stamp + interval rather than a stored deadline: same wrap-safety, but the full 49.7-day
// range instead of 24.8 days ahead, and Time::getMillis() makes the wait injectable.
const uint32_t waitStartMs = Time::getMillis();
#if GPS_DEBUG
std::string debugmsg = "";
#endif
while (millis() < startTimeout) {
while (Throttle::isWithinTimespanMs(waitStartMs, waitMillis)) {
if (_serial_gps->available()) {
b = _serial_gps->read();
@@ -1422,6 +1425,29 @@ void GPS::publishUpdate()
}
}
/// Is a post-lock ephemeris hold currently in force? The `!= 0` is the "never armed" sentinel, which
/// deadlinePassed() reads as passed for the first half of each wrap cycle and as ~24.8 days in the
/// future for the second. No header: test_gps_fix_hold declares the prototypes itself.
bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs)
{
return fixHoldEnds != 0 && !Throttle::deadlinePassed(fixHoldEnds + threadIntervalMs);
}
/// Did an armed hold just expire? `!= 0` guards against negating fixHoldInForce() alone, which would
/// call an unarmed hold "expired" every cycle. No grace interval: the deadline itself is go-down time.
bool holdJustExpired(uint32_t fixHoldEnds)
{
return fixHoldEnds != 0 && !fixHoldInForce(fixHoldEnds, 0);
}
/// Should a post-lock ephemeris hold be (re-)armed this cycle? "No hold in force" fires often, since
/// every publish clears the hold, including ones that don't put the receiver back to sleep.
bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs)
{
// First lock of a cycle, first lock after the receiver was off, or nothing holding right now.
return !hasValidLocation || prevFixQual == 0 || !fixHoldInForce(fixHoldEnds, threadIntervalMs);
}
int32_t GPS::runOnce()
{
#if defined(SENSECAP_INDICATOR)
@@ -1522,13 +1548,15 @@ int32_t GPS::runOnce()
if (updateInterval <= GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) {
hasValidLocation = true;
shouldPublish = true;
} else if (!hasValidLocation || prev_fixQual == 0 || (fixHoldEnds + GPS_THREAD_INTERVAL) < millis()) {
} else if (shouldArmFixHold(hasValidLocation, prev_fixQual, fixHoldEnds, GPS_THREAD_INTERVAL)) {
hasValidLocation = true;
// Hold for up to 20secs after getting a lock to download ephemeris etc
uint32_t holdTime = updateInterval - GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS;
if (holdTime > GPS_FIX_HOLD_MAX_MS)
holdTime = GPS_FIX_HOLD_MAX_MS;
fixHoldEnds = millis() + holdTime;
// Same clock the Throttle evaluation reads, and never the "no hold" sentinel.
const uint32_t holdEnds = Time::getMillis() + holdTime;
fixHoldEnds = holdEnds == 0 ? 1 : holdEnds;
LOG_DEBUG_GPS("Holding for %ums after lock", holdTime);
}
}
@@ -1546,7 +1574,7 @@ int32_t GPS::runOnce()
}
// Hold has expired , Search time has expired, we got a time only, or we never needed to hold.
bool holdExpired = (fixHoldEnds != 0 && millis() > fixHoldEnds);
bool holdExpired = holdJustExpired(fixHoldEnds);
if (shouldPublish || tooLong || holdExpired) {
if (gotTime && hasValidLocation) {
shouldPublish = true;
@@ -1563,7 +1591,7 @@ int32_t GPS::runOnce()
#if GPS_DEBUG
} else if (fixHoldEnds != 0) {
LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - millis(), p.sats_in_view);
LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - Time::getMillis(), p.sats_in_view);
#endif
}
}
+29 -20
View File
@@ -1,10 +1,12 @@
#include "gps/RTC.h"
#include "UptimeClock.h"
#include "configuration.h"
#include "detect/ScanI2C.h"
#include "detect/ScanI2CTwoWire.h"
#include "gps/GPSLog.h"
#include "main.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "modules/NodeInfoModule.h"
#include <Throttle.h>
#include <sys/time.h>
@@ -26,9 +28,12 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual
LOG_DEBUG("Time source acquired (%s -> %s), recheck NodeInfo", RtcName(oldQuality), RtcName(newQuality));
nodeInfoModule->triggerImmediateNodeInfoCheck();
}
if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) {
if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet) {
LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality));
service->reconcilePendingRxTimes();
if (service)
service->reconcilePendingRxTimes();
if (nodeDB)
nodeDB->backfillHeardAt();
}
}
@@ -38,8 +43,9 @@ RTCQuality getRTCQuality()
}
// stuff that really should be in in the instance instead...
static uint32_t
timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time
// The Time::getMillisMonotonic() instant corresponding to zeroOffsetSecs. 64-bit so getTime()'s
// elapsed term cannot wrap: a 32-bit anchor walks the wall clock back 49.7 days per millis() cycle.
static uint64_t timeStartMs64;
static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock
#ifdef PIO_UNIT_TESTING
@@ -71,11 +77,11 @@ static struct timeval mockSystemTime = {};
{
struct timeval tv;
if (readSystemTime(&tv)) {
uint32_t now = millis();
const uint64_t now = Time::getMillisMonotonic();
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
if (currentQuality == RTCQualityNone) {
LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch);
timeStartMsec = now;
timeStartMs64 = now;
zeroOffsetSecs = tv.tv_sec;
} else {
LOG_DEBUG("Ignore system clock fallback (%lu); RTC quality is %s", (unsigned long)printableEpoch,
@@ -101,7 +107,7 @@ RTCSetResult readFromRTC()
[[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/
#ifdef RV3028_RTC
if (rtc_found.address == RV3028_RTC) {
uint32_t now = millis();
const uint64_t now = Time::getMillisMonotonic();
Melopero_RV3028 rtc;
#if WIRE_INTERFACES_COUNT == 2
rtc.initI2C(*ScanI2CTwoWire::fetchI2CBus(rtc_found));
@@ -132,7 +138,7 @@ RTCSetResult readFromRTC()
t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch);
if (currentQuality == RTCQualityNone) {
RTCQuality oldQuality = currentQuality;
timeStartMsec = now;
timeStartMs64 = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
onTimeSourceQualityChanged(oldQuality, currentQuality);
@@ -150,7 +156,7 @@ RTCSetResult readFromRTC()
SensorPCF85063 rtc;
#endif
uint32_t now = millis();
const uint64_t now = Time::getMillisMonotonic();
#if WIRE_INTERFACES_COUNT == 2
rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found));
@@ -178,7 +184,7 @@ RTCSetResult readFromRTC()
t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch);
if (currentQuality == RTCQualityNone) {
RTCQuality oldQuality = currentQuality;
timeStartMsec = now;
timeStartMs64 = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
onTimeSourceQualityChanged(oldQuality, currentQuality);
@@ -189,7 +195,7 @@ RTCSetResult readFromRTC()
}
#elif defined(RX8130CE_RTC)
if (rtc_found.address == RX8130CE_RTC) {
uint32_t now = millis();
const uint64_t now = Time::getMillisMonotonic();
#ifdef MUZI_BASE
ArtronShop_RX8130CE rtc(&Wire1);
#else
@@ -214,7 +220,7 @@ RTCSetResult readFromRTC()
#endif
if (currentQuality == RTCQualityNone) {
RTCQuality oldQuality = currentQuality;
timeStartMsec = now;
timeStartMs64 = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
onTimeSourceQualityChanged(oldQuality, currentQuality);
@@ -224,7 +230,7 @@ RTCSetResult readFromRTC()
}
#elif HAS_LSE
if (stm32wlRtcAvailable()) {
uint32_t now = millis();
const uint64_t now = Time::getMillisMonotonic();
tv.tv_sec = STM32RTC::getInstance().getEpoch();
tv.tv_usec = 0;
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
@@ -239,7 +245,7 @@ RTCSetResult readFromRTC()
#endif
if (currentQuality == RTCQualityNone) {
RTCQuality oldQuality = currentQuality;
timeStartMsec = now;
timeStartMs64 = now;
zeroOffsetSecs = tv.tv_sec;
currentQuality = RTCQualityDevice;
onTimeSourceQualityChanged(oldQuality, currentQuality);
@@ -264,7 +270,8 @@ RTCSetResult readFromRTC()
RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpdate)
{
static uint32_t lastSetMsec = 0;
uint32_t now = millis();
const uint64_t now64 = Time::getMillisMonotonic();
const uint32_t now = (uint32_t)now64; // low word == getMillis(); fine for the Throttle-checked stamps below
uint32_t printableEpoch = tv->tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
#ifdef BUILD_EPOCH
if (tv->tv_sec < BUILD_EPOCH) {
@@ -314,7 +321,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
}
// This delta value works on all platforms
timeStartMsec = now;
timeStartMs64 = now64;
zeroOffsetSecs = tv->tv_sec;
// If this platform has a settable RTC, set it
#ifdef RV3028_RTC
@@ -486,10 +493,12 @@ int32_t getTZOffset()
*/
uint32_t getTime(bool local)
{
// Both terms are 64-bit monotonic, so the elapsed time cannot wrap - see timeStartMs64.
const uint64_t elapsedSecs = (Time::getMillisMonotonic() - timeStartMs64) / 1000;
if (local) {
return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs + getTZOffset();
return elapsedSecs + zeroOffsetSecs + getTZOffset();
} else {
return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs;
return elapsedSecs + zeroOffsetSecs;
}
}
@@ -509,7 +518,7 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot)
{
currentQuality = RTCQualityNone;
zeroOffsetSecs = 0;
timeStartMsec = millis() - (secondsSinceBoot * 1000);
timeStartMs64 = Time::getMillisMonotonic() - ((uint64_t)secondsSinceBoot * 1000);
lastSetFromPhoneNtpOrGps = 0;
lastTimeValidationWarning = 0;
}
@@ -538,7 +547,7 @@ void setReadFromRTCUseSystemTimeForTests(bool enabled)
void resetRTCStateForTests()
{
currentQuality = RTCQualityNone;
timeStartMsec = 0;
timeStartMs64 = 0;
zeroOffsetSecs = 0;
lastSetFromPhoneNtpOrGps = 0;
lastTimeValidationWarning = 0;
+1 -3
View File
@@ -232,9 +232,7 @@ void EInkDynamicDisplay::checkForPromotion()
// Is it too soon for another frame of this type?
void EInkDynamicDisplay::checkRateLimiting()
{
// Sanity check: millis() overflow - just let the update run..
if (previousRunMs > millis())
return;
// No millis()-overflow guard needed: the Throttle checks below are wrap-correct already.
// Skip update: too soon for BACKGROUND
if (frameFlags == BACKGROUND) {
+3 -3
View File
@@ -1112,7 +1112,7 @@ int32_t Screen::runOnce()
// Show boot screen for first logo_timeout seconds, then switch to normal operation.
// serialSinceMsec adjusts for additional serial wait time during nRF52 bootup
static bool showingBootScreen = true;
if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) {
if (showingBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout)) {
LOG_INFO("Done with boot screen");
stopBootScreen();
showingBootScreen = false;
@@ -1120,8 +1120,8 @@ int32_t Screen::runOnce()
#ifdef USERPREFS_OEM_TEXT
static bool showingOEMBootScreen = true;
if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) {
LOG_INFO("Switch to OEM screen");
if (showingOEMBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout / 2)) {
LOG_INFO("Switch to OEM screen...");
// Change frames.
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
+6 -3
View File
@@ -12,6 +12,7 @@
#include "graphics/images.h"
#include "input/RotaryEncoderInterruptImpl1.h"
#include "input/UpDownInterruptImpl1.h"
#include "mesh/Throttle.h"
#if HAS_BUTTON
#include "input/ButtonThread.h"
#endif
@@ -253,7 +254,7 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
// Handle text_input notifications first - they have their own timeout/banner logic
if (current_notification_type == notificationTypeEnum::text_input) {
// Check for timeout and reset if needed for text input
if (millis() > alertBannerUntil && alertBannerUntil > 0) {
if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) {
resetBanner();
return;
}
@@ -261,7 +262,8 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
return;
}
if (millis() > alertBannerUntil && alertBannerUntil > 0) {
// 0 means "no deadline set", and reads as long expired - test it first.
if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) {
resetBanner();
}
@@ -1226,7 +1228,8 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat
bool NotificationRenderer::isOverlayBannerShowing()
{
return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil);
// Here 0 means "show indefinitely", so it must short-circuit the comparison.
return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || !Throttle::deadlinePassed(alertBannerUntil));
}
bool NotificationRenderer::isMenuShowing()
+2 -1
View File
@@ -3,6 +3,7 @@
#include "RotaryEncoderImpl.h"
#include "InputBroker.h"
#include "RotaryEncoder.h"
#include "mesh/Throttle.h"
#ifdef ARCH_ESP32
#include "sleep.h"
#endif
@@ -66,7 +67,7 @@ void RotaryEncoderImpl::pollOnce()
static uint32_t lastPressed = millis();
if (rotary->readButton() == RotaryEncoder::ButtonState::BUTTON_PRESSED) {
if (lastPressed + 200 < millis()) {
if (Throttle::hasElapsed(lastPressed, 200)) {
LOG_DEBUG("Rotary event Press");
lastPressed = millis();
e.inputEvent = this->eventPressed;
+4
View File
@@ -16,6 +16,7 @@
#include "RadioLibInterface.h"
#include "ReliableRouter.h"
#include "TransmitHistory.h"
#include "UptimeClock.h"
#include "airtime.h"
#include "buzz.h"
#include "power/PowerHAL.h"
@@ -1360,6 +1361,9 @@ void loop()
{
runASAP = false;
// The single writer of the monotonic wrap carry; every other caller only reads it.
Time::serviceMonotonic();
#if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL)
if (lockdownDisablePending) {
lockdownDisablePending = false;
+10 -8
View File
@@ -182,14 +182,14 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id)
return nodenum;
}
// Back-calculate the real epoch for any queued packet still carrying a millis() rx_time
// Back-calculate the real epoch for any queued packet still carrying an uptime-seconds rx_time
// placeholder, now that the clock is trustworthy.
void MeshService::reconcilePendingRxTimes()
{
const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
if (nowEpoch == 0) // called before the clock was actually valid - nothing to reconcile against
return;
const uint32_t nowMillis = Time::getMillis();
const uint32_t nowUptimeSecs = Time::getUptimeSecs();
// Rotate the queue once. TypedQueue is strictly FIFO on both backends, so dequeueing and
// re-enqueueing every element in turn leaves the delivery order unchanged.
@@ -198,11 +198,13 @@ void MeshService::reconcilePendingRxTimes()
if (!p) // drained from under us - nothing left to rotate
break;
if (!p->has_rx_time) {
// Unsigned subtraction is wraparound-safe; rx_time is a 32-bit wire field, so the
// placeholder was never wider than 32 bits to begin with.
const uint32_t elapsedMs = nowMillis - p->rx_time;
p->rx_time = nowEpoch - (elapsedMs / 1000);
p->has_rx_time = true;
// Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age.
// If it somehow exceeds the epoch, leave the packet un-dated rather than pre-1970.
const uint32_t elapsedSecs = nowUptimeSecs - p->rx_time;
if (elapsedSecs < nowEpoch) {
p->rx_time = nowEpoch - elapsedSecs;
p->has_rx_time = true;
}
}
if (!toPhoneQueue.enqueue(p, 0)) { // mirrors sendToPhone()'s degrade-on-failure path
LOG_CRIT("Requeue to toPhoneQueue failed");
@@ -627,7 +629,7 @@ bool MeshService::isToPhoneQueueEmpty()
uint32_t MeshService::GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp)
{
// rx_time may be a millis() placeholder while has_rx_time is false - don't age it as
// rx_time may be an uptime-seconds placeholder while has_rx_time is false - don't age it as
// wall-clock, and don't pass it off as "just now" either.
if (!mp->has_rx_time)
return SINCE_UNKNOWN;
+2 -2
View File
@@ -137,8 +137,8 @@ class MeshService
// search the queue for a request id and return the matching nodenum
NodeNum getNodenumFromRequestId(uint32_t request_id);
// Rewrite any queued-for-phone packet still carrying a millis() rx_time placeholder into a
// real epoch, now that the wall clock is trustworthy.
// Rewrite any queued-for-phone packet still carrying an uptime-seconds rx_time placeholder
// into a real epoch, now that the wall clock is trustworthy.
void reconcilePendingRxTimes();
// Release QueueStatus packet to pool
+9 -4
View File
@@ -1,6 +1,8 @@
#include "NextHopRouter.h"
#include "Default.h"
#include "MeshTypes.h"
#include "Throttle.h"
#include "UptimeClock.h"
#include "meshUtils.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
#include "modules/TraceRouteModule.h"
@@ -403,7 +405,9 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint
*/
int32_t NextHopRouter::doRetransmissions()
{
uint32_t now = millis();
// Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an
// injected test clock.
uint32_t now = Time::getMillis();
int32_t d = INT32_MAX;
// FIXME, we should use a better datastructure rather than walking through this map.
@@ -414,8 +418,9 @@ int32_t NextHopRouter::doRetransmissions()
bool stillValid = true; // assume we'll keep this record around
// FIXME, handle 51 day rolloever here!!!
if (p.nextTxMsec <= now) {
// Judged against the snapshot above, so one pass sees one instant and the 49.7 day wrap
// can't stall retransmission.
if (Throttle::deadlinePassedAt(now, p.nextTxMsec)) {
if (p.numRetransmissions == 0) {
if (isFromUs(p.packet)) {
LOG_DEBUG("Reliable send failed, return nak fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from, p.packet->to,
@@ -511,7 +516,7 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
{
assert(iface);
auto d = iface->getRetransmissionMsec(pending->packet);
pending->nextTxMsec = millis() + d;
pending->nextTxMsec = Time::getMillis() + d;
LOG_TRACE("Next retransmission in %u msecs", d);
printPacket("", pending->packet);
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
+103 -13
View File
@@ -19,6 +19,7 @@
#include "SafeFile.h"
#include "TransmitHistory.h"
#include "TypeConversions.h"
#include "UptimeClock.h"
#include "error.h"
#include "gps/RTC.h"
#include "main.h"
@@ -3269,7 +3270,7 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n)
uint32_t sinceReceived(const meshtastic_MeshPacket *p)
{
// rx_time may be a millis() placeholder while has_rx_time is false - don't age it as
// rx_time may be an uptime-seconds placeholder while has_rx_time is false - don't age it as
// wall-clock, and don't pass it off as "just now" either.
if (!p->has_rx_time)
return SINCE_UNKNOWN;
@@ -3516,16 +3517,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
// Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
// without the user doing so deliberately. We don't normally expect users to use a CLIENT_BASE to send DMs or to add
// contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll set
// last_heard to now, so that the add_contact node doesn't immediately get evicted.
info->last_heard = getTime();
// contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll
// stamp the contact as heard now, so that the add_contact node doesn't immediately get evicted.
stampContactHeardNow(info);
} else {
// Normal case: set is_favorite to prevent expiration.
// last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB).
// If the protected cap refuses the favorite, fall back to stamping last_heard so the
// If the protected cap refuses the favorite, fall back to a heard-now stamp so the
// contact still isn't the first eviction victim.
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true))
info->last_heard = getTime();
stampContactHeardNow(info);
}
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
@@ -3678,9 +3679,13 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
return;
}
// Gate on has_rx_time, not truthiness - rx_time may hold a millis() placeholder.
// Gate on has_rx_time, not truthiness - rx_time may hold an uptime-seconds placeholder.
if (mp.has_rx_time)
info->last_heard = mp.rx_time;
else
// rx_time is the arrival instant in uptime seconds. It goes to the RAM sidecar, not
// last_heard, which only ever holds a real epoch or 0.
recordHeardWhileClockUntrusted(getFrom(&mp), mp.rx_time);
// Gate on the packet actually having been received over our own radio, not on rx_snr being
// truthy, because 0 dB is valid. TRANSPORT_LORA is set only on the real over-the-air RX path
@@ -4096,6 +4101,84 @@ meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
return meshtastic_Config_DeviceConfig_Role_CLIENT;
}
void NodeDB::recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptime)
{
// Update in place if the node already has a stamp.
for (auto &h : heardAt) {
if (h.num == num) {
h.heardAtUptimeSecs = heardAtUptime;
return;
}
}
// Otherwise take an empty slot, or reuse the oldest stamp.
NodeHeardAt *victim = &heardAt[0];
for (auto &h : heardAt) {
if (h.num == 0) {
victim = &h;
break;
}
if (h.heardAtUptimeSecs < victim->heardAtUptimeSecs)
victim = &h;
}
victim->num = num;
victim->heardAtUptimeSecs = heardAtUptime;
}
bool NodeDB::getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const
{
for (const auto &h : heardAt) {
if (h.num == num) {
stamp = h.heardAtUptimeSecs;
return true;
}
}
return false;
}
NodeDB::EvictionRecency NodeDB::evictionRecency(const meshtastic_NodeInfoLite *n) const
{
uint32_t stamp = 0;
const bool heardThisBoot = getHeardAtUptimeSecs(n->num, stamp);
return {heardThisBoot ? stamp : n->last_heard, heardThisBoot};
}
bool NodeDB::evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent)
{
if (candidate.heardThisBoot != incumbent.heardThisBoot)
return !candidate.heardThisBoot;
return candidate.value < incumbent.value;
}
void NodeDB::stampContactHeardNow(meshtastic_NodeInfoLite *info)
{
const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
if (nowEpoch)
info->last_heard = nowEpoch;
else
recordHeardWhileClockUntrusted(info->num, Time::getUptimeSecs());
}
void NodeDB::backfillHeardAt()
{
const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
if (nowEpoch == 0) // called before the clock was actually valid - nothing to date against
return;
const uint32_t nowUptimeSecs = Time::getUptimeSecs();
for (auto &h : heardAt) {
if (h.num == 0)
continue;
meshtastic_NodeInfoLite *info = getMeshNode(h.num);
if (info) {
// Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age.
// Never move last_heard backwards: the node may since have been re-heard on a good clock.
const uint32_t elapsedSecs = nowUptimeSecs - h.heardAtUptimeSecs;
if (elapsedSecs < nowEpoch && nowEpoch - elapsedSecs > info->last_heard)
info->last_heard = nowEpoch - elapsedSecs;
}
h = {}; // evicted or converted either way, the stamp's job is done
}
}
/// Find a node in our DB, create an empty NodeInfo if missing
meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
{
@@ -4105,8 +4188,10 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
if (isFull()) {
LOG_INFO("Node database full: %i nodes, %u bytes free. Erase oldest", numMeshNodes, memGet.getFreeHeap());
// look for oldest node and erase it
uint32_t oldest = UINT32_MAX;
uint32_t oldestBoring = UINT32_MAX;
// Newest-possible sentinel: a zeroed init ranks older than every candidate, so nothing
// would ever be selected. Keep it maximal even though the index guards below also cover it.
EvictionRecency oldest = {UINT32_MAX, true};
EvictionRecency oldestBoring = {UINT32_MAX, true};
int oldestIndex = -1;
int oldestBoringIndex = -1;
for (int i = 1; i < numMeshNodes; i++) {
@@ -4114,14 +4199,19 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand);
const bool isIgnored = nodeInfoLiteIsIgnored(cand);
const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand);
// last_heard, except that nodes heard this boot before the clock became trusted
// rank by their RAM arrival stamp instead of the 0 in the stored field.
const EvictionRecency candRecency = evictionRecency(cand);
// Simply the oldest non-favorite, non-ignored, non-verified node
if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) {
oldest = cand->last_heard;
if (!isFavoriteNode && !isIgnored && !isVerified &&
(oldestIndex == -1 || evictionRecencyOlder(candRecency, oldest))) {
oldest = candRecency;
oldestIndex = i;
}
// The oldest "boring" node
if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) {
oldestBoring = cand->last_heard;
if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 &&
(oldestBoringIndex == -1 || evictionRecencyOlder(candRecency, oldestBoring))) {
oldestBoring = candRecency;
oldestBoringIndex = i;
}
}
+37
View File
@@ -248,6 +248,14 @@ enum LoadFileResult {
enum UserLicenseStatus { NotKnown, NotLicensed, Licensed };
// RAM-only arrival stamp (monotonic uptime secs) for nodes heard before the wall clock was trusted,
// backfilled into last_heard as an epoch once it is. last_heard persists, so it cannot hold this.
// Bounded, linear-scan, reuse-oldest, never persisted - dies with the boot, as does its timebase.
struct NodeHeardAt {
NodeNum num = 0; ///< node this stamp describes; 0 == empty slot
uint32_t heardAtUptimeSecs = 0; ///< Time::getUptimeSecs() when last heard
};
class NodeDB
{
// NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt
@@ -308,6 +316,10 @@ class NodeDB
void addFromContact(const meshtastic_SharedContact);
/// On the clock-becoming-trusted transition (see RTC.cpp): convert every RAM arrival stamp into
/// a real last_heard epoch, never backwards, then empty the table. updateFrom() takes over.
void backfillHeardAt();
/** Update position info for this node based on received position data
*/
void updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src = RX_SRC_RADIO);
@@ -638,6 +650,31 @@ class NodeDB
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
/// See NodeHeardAt. Caps how many distinct nodes can be dated once the clock arrives; a node
/// pushed out by reuse-oldest just stays "last heard: unknown", the same as before this table.
static constexpr size_t kMaxHeardAt = 32;
NodeHeardAt heardAt[kMaxHeardAt] = {};
/// Stamp (or re-stamp) a node's RAM arrival record; used instead of writing a non-epoch into
/// last_heard whenever the wall clock is untrusted.
void recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptimeSecs);
/// addFromContact's anti-eviction stamp: a real epoch when the clock is trusted, otherwise a
/// RAM arrival stamp that evictionRecency() honours - never a boot-relative last_heard.
void stampContactHeardNow(meshtastic_NodeInfoLite *info);
/// Read the node's RAM arrival stamp. The boolean carries presence because uptime second 0 is valid.
bool getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const;
struct EvictionRecency {
uint32_t value;
bool heardThisBoot;
};
/// Eviction ranking with current-boot stamps newer than every persisted epoch.
EvictionRecency evictionRecency(const meshtastic_NodeInfoLite *n) const;
static bool evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent);
/*
* Internal boolean to track sorting paused
*/
+8
View File
@@ -972,6 +972,14 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
}
if (infoToSend.num != 0) {
// A record prefetched before the clock became trusted carries last_heard == 0 even
// once the store is backfilled, so re-read it at send time: handshake ordering
// (time-set vs node-list download) must not decide what the phone sees.
if (infoToSend.last_heard == 0 && infoToSend.num != nodeDB->getNodeNum()) {
const meshtastic_NodeInfoLite *fresh = nodeDB->getMeshNode(infoToSend.num);
if (fresh)
infoToSend.last_heard = fresh->last_heard;
}
// Just in case we stored a different user.id in the past, but should never happen going forward
sprintf(infoToSend.user.id, "!%08x", infoToSend.num);
+1 -1
View File
@@ -318,7 +318,7 @@ PacketId generatePacketId()
RxTimeStamp computeRxTimeStamp()
{
const bool haveTime = getRTCQuality() >= RTCQualityFromNet;
return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getMillis(), haveTime};
return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getUptimeSecs(), haveTime};
}
void stampRxTime(meshtastic_MeshPacket *p)
+2 -1
View File
@@ -21,7 +21,8 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p);
bool willUsePki(const meshtastic_MeshPacket *p);
/// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a
/// Time::getMillis() placeholder with valid=false.
/// Time::getUptimeSecs() placeholder with valid=false. Uptime seconds are monotonic, so
/// reconciliation against a later epoch is exact at any age.
struct RxTimeStamp {
uint32_t time;
bool valid;
+12 -3
View File
@@ -1,4 +1,5 @@
#include "Throttle.h"
#include "UptimeClock.h"
#include <Arduino.h>
/// @brief Execute a function throttled to a minimum interval
@@ -10,11 +11,11 @@
bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*throttleFunc)(void), void (*onDefer)(void))
{
if (*lastExecutionMs == 0) {
*lastExecutionMs = millis();
*lastExecutionMs = Time::getMillis();
throttleFunc();
return true;
}
uint32_t now = millis();
uint32_t now = Time::getMillis();
if ((now - *lastExecutionMs) >= minumumIntervalMs) {
throttleFunc();
@@ -31,6 +32,14 @@ bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, vo
/// @param timeSpanMs The interval in milliseconds of the timespan
bool Throttle::isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t timeSpanMs)
{
uint32_t now = millis();
uint32_t now = Time::getMillis();
return (now - lastExecutionMs) < timeSpanMs;
}
/// @brief Check whether an absolute deadline has arrived, correctly across the millis() wrap
/// @param deadlineMs The deadline, as a millis() value
/// See the header for the range limit and the sentinel requirement.
bool Throttle::deadlinePassed(uint32_t deadlineMs)
{
return deadlinePassedAt(Time::getMillis(), deadlineMs);
}
+44
View File
@@ -7,4 +7,48 @@ class Throttle
public:
static bool execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*func)(void), void (*onDefer)(void) = NULL);
static bool isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t intervalMs);
/// Complement of isWithinTimespanMs(): true once intervalMs has passed since lastExecutionMs.
/// Boundary is inclusive (>=), mirroring isWithinTimespanMs()'s exclusive <.
/// Deliberately does not treat lastExecutionMs == 0 as "never run" - callers that use 0 as a
/// sentinel must test for it separately, so the sentinel never reaches the arithmetic.
static bool hasElapsed(uint32_t lastExecutionMs, uint32_t intervalMs)
{
return !isWithinTimespanMs(lastExecutionMs, intervalMs);
}
/// True once an absolute deadline has arrived. Use this rather than comparing against millis()
/// directly: that inverts while the deadline sits on the far side of the 32-bit wrap, so the
/// action either fires immediately or blocks for about the interval it should have waited.
///
/// Use this when the site stores a deadline; use hasElapsed() when it stores the time of the
/// last event, which allows the full ~49.7 day range instead of ~24.8 days ahead.
///
/// Callers that overload the deadline with an "inactive" sentinel (0, or UINT32_MAX) MUST test
/// for that separately, first: every such value is arithmetically far in the past, so it reads
/// as passed.
///
/// TODO(deadline-type): mistake-proof that MUST by giving a deadline its own one-field type -
/// Deadline::in(ms) / .armed() / .passed() / .disarm(). A hand-built `now + interval` could then
/// no longer land on the sentinel by accident, and "armed" would stay a question separate from
/// "passed" - the split that has to survive, because which way "inactive" falls is the caller's
/// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four
/// meanings they give the sentinel today:
/// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec (the cheapest pair to convert), and
/// GPS.cpp fixHoldEnds, whose arm site remaps a 0 result to 1 by hand.
/// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0`
/// guard, so this third state wants naming rather than repeating.
/// 0 = due now - ethClient.cpp ntp_renew, forced at link-up.
/// UINT32_MAX - ExternalNotificationModule.cpp nagCycleCutoff, whose armed() also lives in a
/// second variable (isNagging) and whose arm site can land on the sentinel.
static bool deadlinePassed(uint32_t deadlineMs);
/// deadlinePassed() against a caller-supplied "now", for a loop that snapshots the time once and
/// tests many deadlines against it. Same range limit and sentinel rules as above.
static bool deadlinePassedAt(uint32_t nowMs, uint32_t deadlineMs)
{
// Passed iff now - deadline has not wrapped past 2^31 ms; further-ahead deadlines land in
// the top half. Not an int32_t cast, which is implementation-defined beyond INT32_MAX.
return (uint32_t)(nowMs - deadlineMs) < 0x80000000u;
}
};
+4 -1
View File
@@ -4,6 +4,7 @@
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include "mesh/Throttle.h"
#include "mesh/api/ethServerAPI.h"
#include "target_specific.h"
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
@@ -196,7 +197,9 @@ static int32_t reconnectETH()
}
#ifndef DISABLE_NTP
if (isEthernetAvailable() && (ntp_renew < millis())) {
// 0 here means "renew now" (forced at link-up). deadlinePassed(0) only reads as passed for the
// first half of each wrap cycle, so treat 0 as always-due rather than relying on that.
if (isEthernetAvailable() && (ntp_renew == 0 || Throttle::deadlinePassed(ntp_renew))) {
LOG_INFO("Update NTP time from %s", config.network.ntp_server);
if (timeClient.update()) {
+2 -1
View File
@@ -12,6 +12,7 @@
#include "modules/Telemetry/Sensor/DFRobotLarkSensor.h"
#include "modules/Telemetry/UnitConversions.h"
#include "mesh/Throttle.h"
#include <string>
DropzoneModule *dropzoneModule;
@@ -19,7 +20,7 @@ DropzoneModule *dropzoneModule;
int32_t DropzoneModule::runOnce()
{
// Send on a 5 second delay from receiving the matching request
if (startSendConditions != 0 && (startSendConditions + 5000U) < millis()) {
if (startSendConditions != 0 && Throttle::hasElapsed(startSendConditions, 5000U)) {
service->sendToMesh(sendConditions(), RX_SRC_LOCAL);
startSendConditions = 0;
}
+12 -7
View File
@@ -21,6 +21,7 @@
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include "mesh/Throttle.h"
#include "mesh/generated/meshtastic/rtttl.pb.h"
#include <Arduino.h>
@@ -85,7 +86,10 @@ int32_t ExternalNotificationModule::runOnce()
#if defined(HAS_I2S_SPEAKER_NRF52)
isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying();
#endif
if ((nagCycleCutoff < millis()) && !isRtttlPlaying) {
// isNagging is the armed flag; nagCycleCutoff holds a real deadline only while it is set
// (UINT32_MAX once stopped, 1 at boot), so short-circuit before the comparison.
const bool nagWindowExpired = !isNagging || Throttle::deadlinePassed(nagCycleCutoff);
if (nagWindowExpired && !isRtttlPlaying) {
// Turn off external notification immediately when timeout is reached, regardless of song state
nagCycleCutoff = UINT32_MAX;
ExternalNotificationModule::stopNow();
@@ -97,14 +101,15 @@ int32_t ExternalNotificationModule::runOnce()
if (isNagging) {
delay = (moduleConfig.external_notification.output_ms ? moduleConfig.external_notification.output_ms
: EXT_NOTIFICATION_MODULE_OUTPUT_MS);
if (externalTurnedOn[0] + delay < millis()) {
// externalTurnedOn[] is when each output was last toggled, so these are intervals.
if (Throttle::hasElapsed(externalTurnedOn[0], delay)) {
setExternalState(0, !getExternal(0));
}
if (externalTurnedOn[1] + delay < millis()) {
if (Throttle::hasElapsed(externalTurnedOn[1], delay)) {
setExternalState(1, !getExternal(1));
}
// Only toggle buzzer output if not using PWM mode (to avoid conflict with RTTTL)
if (!moduleConfig.external_notification.use_pwm && externalTurnedOn[2] + delay < millis()) {
if (!moduleConfig.external_notification.use_pwm && Throttle::hasElapsed(externalTurnedOn[2], delay)) {
LOG_DEBUG("EXTERNAL 2 %d compared to %d", externalTurnedOn[2] + moduleConfig.external_notification.output_ms,
millis());
setExternalState(2, !getExternal(2));
@@ -146,7 +151,7 @@ int32_t ExternalNotificationModule::runOnce()
if (moduleConfig.external_notification.use_i2s_as_buzzer) {
if (audioThread->isPlaying()) {
// Continue playing
} else if (isNagging && (nagCycleCutoff >= millis())) {
} else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) {
audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone));
}
// we need fast updates to play the RTTTL
@@ -158,7 +163,7 @@ int32_t ExternalNotificationModule::runOnce()
if (canBuzz() && buzzerShouldAlert) {
if (nrf52RtttlPlayer.isPlaying()) {
nrf52RtttlPlayer.play();
} else if (isNagging && (nagCycleCutoff >= millis())) {
} else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) {
nrf52RtttlPlayer.begin(rtttlConfig.ringtone);
}
delay = EXT_NOTIFICATION_FAST_THREAD_MS;
@@ -168,7 +173,7 @@ int32_t ExternalNotificationModule::runOnce()
if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) {
if (rtttl::isPlaying()) {
rtttl::play();
} else if (isNagging && (nagCycleCutoff >= millis())) {
} else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) {
// start the song again if we have time left
rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone);
}
+17 -13
View File
@@ -34,17 +34,14 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
// Suppress replies to senders we've replied to recently (12H window)
if (mp.decoded.want_response && !isFromUs(&mp)) {
const NodeNum sender = getFrom(&mp);
// A local dedup window, not a wall-clock reading - uptime avoids RTC-quality jumps and
// replayed packets' stale rx_time perturbing it.
const uint32_t now = (uint32_t)(Time::getMillis64() / 1000);
// A local dedup window, not a wall-clock reading - uptime avoids RTC jumps and replayed
// packets' stale rx_time perturbing it. Seconds, not millis - this is a wide window.
const uint32_t nowSecs = Time::getUptimeSecs();
auto it = lastNodeInfoSeen.find(sender);
if (it != lastNodeInfoSeen.end()) {
uint32_t sinceLast = now >= it->second ? now - it->second : 0;
if (sinceLast < NodeInfoReplySuppressSeconds) {
suppressReplyForCurrentRequest = true;
}
if (it != lastNodeInfoSeen.end() && (uint32_t)(nowSecs - it->second) < NodeInfoReplySuppressSeconds) {
suppressReplyForCurrentRequest = true;
}
lastNodeInfoSeen[sender] = now;
lastNodeInfoSeen[sender] = nowSecs;
pruneLastNodeInfoCache();
}
@@ -193,19 +190,26 @@ void NodeInfoModule::pruneLastNodeInfoCache()
return;
const size_t maxEntries = nodeDB->meshNodes->size();
const uint32_t nowSecs = Time::getUptimeSecs();
// Drop entries for nodes we no longer know, and any stamp already past the suppression window:
// it can only decide "don't suppress", so keeping it buys nothing.
for (auto it = lastNodeInfoSeen.begin(); it != lastNodeInfoSeen.end();) {
if (!nodeDB->getMeshNode(it->first)) {
if (!nodeDB->getMeshNode(it->first) || (uint32_t)(nowSecs - it->second) >= NodeInfoReplySuppressSeconds) {
it = lastNodeInfoSeen.erase(it);
} else {
++it;
}
}
// Evict by largest elapsed time rather than smallest stamp, so the victim is still the oldest
// entry if the uptime counter ever wraps underneath us.
while (!lastNodeInfoSeen.empty() && lastNodeInfoSeen.size() > maxEntries) {
auto oldestIt = std::min_element(lastNodeInfoSeen.begin(), lastNodeInfoSeen.end(),
[](const std::pair<const NodeNum, uint32_t> &lhs,
const std::pair<const NodeNum, uint32_t> &rhs) { return lhs.second < rhs.second; });
auto oldestIt = std::max_element(
lastNodeInfoSeen.begin(), lastNodeInfoSeen.end(),
[nowSecs](const std::pair<const NodeNum, uint32_t> &lhs, const std::pair<const NodeNum, uint32_t> &rhs) {
return (uint32_t)(nowSecs - lhs.second) < (uint32_t)(nowSecs - rhs.second);
});
lastNodeInfoSeen.erase(oldestIt);
}
}
+2
View File
@@ -50,6 +50,8 @@ class NodeInfoModule : public ProtobufModule<meshtastic_User>, private concurren
private:
bool shorterTimeout = false;
bool suppressReplyForCurrentRequest = false;
/// Sender -> uptime seconds (Time::getUptimeSecs()) at our last reply. Seconds, not millis:
/// the suppression window is hours wide. See handleReceivedProtobuf().
std::map<NodeNum, uint32_t> lastNodeInfoSeen;
void pruneLastNodeInfoCache();
+5 -4
View File
@@ -2,6 +2,7 @@
#include "MeshService.h"
#include "configuration.h"
#include "mesh/RadioInterface.h"
#include "mesh/Throttle.h"
#include <Arduino.h>
/*
@@ -118,7 +119,7 @@ int32_t StatusLEDModule::runOnce()
} else if (power_state == charged) {
CHARGE_LED_state = LED_STATE_ON;
} else if (power_state == critical) {
if (POWER_LED_starttime + 30000 < millis() && !doing_fast_blink) {
if (Throttle::hasElapsed(POWER_LED_starttime, 30000) && !doing_fast_blink) {
doing_fast_blink = true;
POWER_LED_starttime = millis();
}
@@ -126,7 +127,7 @@ int32_t StatusLEDModule::runOnce()
PAIRING_LED_state = LED_STATE_OFF;
CHARGE_LED_state = !CHARGE_LED_state;
my_interval = 250;
if (POWER_LED_starttime + 2000 < millis()) {
if (Throttle::hasElapsed(POWER_LED_starttime, 2000)) {
doing_fast_blink = false;
CHARGE_LED_state = LED_STATE_OFF;
}
@@ -165,7 +166,7 @@ int32_t StatusLEDModule::runOnce()
}
#endif
#ifdef LED_PAIRING
if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) {
if (!config.bluetooth.enabled || Throttle::hasElapsed(PAIRING_LED_starttime, 30 * 1000) || doing_fast_blink) {
PAIRING_LED_state = LED_STATE_OFF;
} else if (ble_state == unpaired) {
if (slowTrack) {
@@ -190,7 +191,7 @@ int32_t StatusLEDModule::runOnce()
bool chargeIndicatorLED2 = LED_STATE_OFF;
bool chargeIndicatorLED3 = LED_STATE_OFF;
bool chargeIndicatorLED4 = LED_STATE_OFF;
if (lastUserbuttonTime + 10 * 1000 > millis() || CHARGE_LED_state == LED_STATE_ON) {
if (Throttle::isWithinTimespanMs(lastUserbuttonTime, 10 * 1000) || CHARGE_LED_state == LED_STATE_ON) {
// should this be off at very low percentages?
chargeIndicatorLED1 = LED_STATE_ON;
if (powerStatus && powerStatus->getBatteryChargePercent() >= 25)
+10 -9
View File
@@ -6,7 +6,9 @@
#include "PowerFSM.h"
#include "RadioLibInterface.h"
#include "Router.h"
#include "Throttle.h"
#include "TransmitHistory.h"
#include "UptimeClock.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
@@ -21,13 +23,12 @@ static constexpr uint16_t TX_HISTORY_KEY_DEVICE_TELEMETRY = 0x8001;
int32_t DeviceTelemetryModule::runOnce()
{
refreshUptime();
uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_DEVICE_TELEMETRY) : 0;
bool isImpoliteRole = isSensorOrRouterRole();
if (((lastTelemetry == 0) ||
((uptimeLastMs - lastTelemetry) >= Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.device_update_interval,
default_telemetry_broadcast_interval_secs,
numOnlineNodes, TrafficType::TELEMETRY))) &&
if (((lastTelemetry == 0) || Throttle::hasElapsed(lastTelemetry, Default::getConfiguredOrDefaultMsScaled(
moduleConfig.telemetry.device_update_interval,
default_telemetry_broadcast_interval_secs,
numOnlineNodes, TrafficType::TELEMETRY))) &&
airTime->isTxAllowedChannelUtil(!isImpoliteRole) && airTime->isTxAllowedAirUtil() &&
config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN &&
moduleConfig.telemetry.device_telemetry_enabled) {
@@ -38,9 +39,9 @@ int32_t DeviceTelemetryModule::runOnce()
// Just send to phone when it's not our time to send to mesh yet
// Only send while queue is empty (phone assumed connected)
sendTelemetry(NODENUM_BROADCAST, true);
if (lastSentStatsToPhone == 0 || (uptimeLastMs - lastSentStatsToPhone) >= sendStatsToPhoneIntervalMs) {
if (lastSentStatsToPhone == 0 || Throttle::hasElapsed(lastSentStatsToPhone, sendStatsToPhoneIntervalMs)) {
sendLocalStatsToPhone();
lastSentStatsToPhone = uptimeLastMs;
lastSentStatsToPhone = Time::getMillis();
}
}
return sendToPhoneIntervalMs;
@@ -114,7 +115,7 @@ meshtastic_Telemetry DeviceTelemetryModule::getDeviceTelemetry()
t.variant.device_metrics.has_voltage = true;
t.variant.device_metrics.voltage = batteryMv / 1000.0f;
}
t.variant.device_metrics.uptime_seconds = getUptimeSeconds();
t.variant.device_metrics.uptime_seconds = Time::getUptimeSecs();
return t;
}
@@ -124,7 +125,7 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry()
telemetry.which_variant = meshtastic_Telemetry_local_stats_tag;
telemetry.variant.local_stats = meshtastic_LocalStats_init_zero;
telemetry.time = getTime();
telemetry.variant.local_stats.uptime_seconds = getUptimeSeconds();
telemetry.variant.local_stats.uptime_seconds = Time::getUptimeSecs();
telemetry.variant.local_stats.channel_utilization = airTime->channelUtilizationPercent();
telemetry.variant.local_stats.air_util_tx = airTime->utilizationTXPercent();
telemetry.variant.local_stats.num_online_nodes = numOnlineNodes;
-21
View File
@@ -18,8 +18,6 @@ class DeviceTelemetryModule : private concurrency::OSThread,
: concurrency::OSThread("DeviceTelemetry"),
ProtobufModule("DeviceTelemetry", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg)
{
uptimeWrapCount = 0;
uptimeLastMs = millis();
nodeStatusObserver.observe(&nodeStatus->onNewStatus);
setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent
}
@@ -37,12 +35,6 @@ class DeviceTelemetryModule : private concurrency::OSThread,
*/
bool sendTelemetry(NodeNum dest = NODENUM_BROADCAST, bool phoneOnly = false);
/**
* Get the uptime in seconds
* Loses some accuracy after 49 days, but that's fine
*/
uint32_t getUptimeSeconds() { return (0xFFFFFFFF / 1000) * uptimeWrapCount + (uptimeLastMs / 1000); }
private:
meshtastic_Telemetry getDeviceTelemetry();
meshtastic_Telemetry getLocalStatsTelemetry();
@@ -51,17 +43,4 @@ class DeviceTelemetryModule : private concurrency::OSThread,
uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute
uint32_t sendStatsToPhoneIntervalMs = 15 * SECONDS_IN_MINUTE * 1000; // Send stats to phone every 15 minutes
uint32_t lastSentStatsToPhone = 0;
void refreshUptime()
{
auto now = millis();
// If we wrapped around (~49 days), increment the wrap count
if (now < uptimeLastMs)
uptimeWrapCount++;
uptimeLastMs = now;
}
uint32_t uptimeWrapCount;
uint32_t uptimeLastMs;
};
-4
View File
@@ -12,8 +12,6 @@ class HostMetricsModule : private concurrency::OSThread, public ProtobufModule<m
: concurrency::OSThread("HostMetrics"),
ProtobufModule("HostMetrics", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg)
{
uptimeWrapCount = 0;
uptimeLastMs = millis();
nodeStatusObserver.observe(&nodeStatus->onNewStatus);
setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent
}
@@ -35,6 +33,4 @@ class HostMetricsModule : private concurrency::OSThread, public ProtobufModule<m
meshtastic_Telemetry getHostMetrics();
uint32_t lastSentToMesh = 0;
uint32_t uptimeWrapCount;
uint32_t uptimeLastMs;
};
@@ -7,6 +7,8 @@
#include "FSCommon.h"
#include "SPILock.h"
#include "TelemetrySensor.h"
#include "UptimeClock.h"
#include "mesh/Throttle.h"
#if __has_include(<Adafruit_BME680.h>)
#include <cmath>
@@ -163,7 +165,8 @@ void BME680Sensor::updateState()
}
} else {
/* Update every STATE_SAVE_PERIOD minutes */
if ((stateUpdateCounter * STATE_SAVE_PERIOD) < millis()) {
// Interval since the last save; counter * period overflows uint32 past ~198 saves.
if (Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD)) {
LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000);
update = true;
stateUpdateCounter++;
@@ -181,6 +184,8 @@ void BME680Sensor::updateState()
file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE);
file.flush();
file.close();
// Checkpoint on success only, so a failed write is retried at the next interval.
lastStateSaveMs = Time::getMillis();
} else {
LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName);
}
@@ -39,6 +39,7 @@ class BME680Sensor : public TelemetrySensor
uint8_t bsecState[BSEC_MAX_STATE_BLOB_SIZE] = {0};
uint8_t accuracy = 0;
uint16_t stateUpdateCounter = 0;
uint32_t lastStateSaveMs = 0; // when the state blob was last written, for the save interval
bsecSensor sensorList[9] = {BSEC_OUTPUT_IAQ,
BSEC_OUTPUT_RAW_TEMPERATURE,
BSEC_OUTPUT_RAW_PRESSURE,
+5 -2
View File
@@ -259,8 +259,11 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState
const uint32_t now = millis();
const uint32_t endCalibrationAt = screen->getEndCalibration();
uint32_t timeRemaining = 0;
if (endCalibrationAt > now) {
timeRemaining = (endCalibrationAt - now + 999) / 1000;
// Signed delta, as in finishCalibrationIfExpired(): this needs the remaining magnitude, not
// just whether the deadline passed, so it cannot use Throttle::deadlinePassed().
const int32_t remainingMs = (int32_t)(endCalibrationAt - now);
if (remainingMs > 0) {
timeRemaining = ((uint32_t)remainingMs + 999) / 1000;
}
int16_t compassX = 0, compassY = 0;
@@ -10,6 +10,7 @@
#include "input/InputBroker.h"
#include "input/TouchScreenImpl1.h"
#include "main.h"
#include "mesh/Throttle.h"
#include "sleep.h"
#include <cstring>
@@ -100,7 +101,10 @@ volatile bool touchControllerReady = false;
volatile bool touchLightSleepActive = false;
volatile bool touchNeedsWake = false;
volatile bool touchIndicatorRefreshPending = false;
volatile uint32_t touchResumeBlockUntilMs = 0;
// When the light-sleep resume happened, not when the block expires: an interval bounds a missed
// 0-check by the settle time, where a stored deadline would block for up to half a wrap cycle.
constexpr uint32_t TOUCH_RESUME_BLOCK_MS = 150;
volatile uint32_t touchResumeAtMs = 0;
volatile uint32_t touchStateEpoch = 1;
volatile bool homeCapButtonEventsEnabled = false;
#if HAS_SCREEN
@@ -184,7 +188,8 @@ class SideKeyInterruptThread : public concurrency::OSThread
{
const uint32_t now = millis();
if (now < touchResumeBlockUntilMs) {
// 0 means the device has never light-slept, so no block is armed - test it first.
if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) {
resetStateAndStop();
return OSThread::disable();
}
@@ -279,8 +284,8 @@ class SideKeyInterruptThread : public concurrency::OSThread
if (touchLightSleepActive) {
return;
}
const uint32_t now = millis();
if (now < touchResumeBlockUntilMs) {
// See the runOnce() guard above for why 0 must be tested separately.
if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) {
return;
}
if (state != State::REST) {
@@ -550,7 +555,7 @@ struct TouchLightSleepEndObserver {
}
touchStateEpoch++;
touchResumeBlockUntilMs = millis() + 150;
touchResumeAtMs = millis();
touchIndicatorRefreshPending = !isTouchInputEnabled();
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// Clear sleep-time touch overlay after wake.
@@ -569,17 +574,18 @@ struct TouchLightSleepEndObserver {
bool readTouch(int16_t *x, int16_t *y)
{
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
static uint32_t suppressUntilMs = 0;
constexpr uint32_t TOUCH_WAKE_SUPPRESS_MS = 60;
static uint32_t suppressFromMs = 0; // 0 = not suppressing, same reading as touchResumeAtMs
static uint32_t seenTouchStateEpoch = 0;
// Reset transient gesture helpers whenever touch mode changes.
if (seenTouchStateEpoch != touchStateEpoch) {
seenTouchStateEpoch = touchStateEpoch;
suppressUntilMs = 0;
suppressFromMs = 0;
}
// Let buses and peripherals settle briefly after light-sleep wake.
if (millis() < touchResumeBlockUntilMs) {
// Let buses and peripherals settle briefly after light-sleep wake. 0 means no wake yet.
if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) {
return false;
}
@@ -596,12 +602,12 @@ bool readTouch(int16_t *x, int16_t *y)
LOG_DEBUG("touchscreen1: wakeup() on deferred resume");
touch.wakeup();
touchNeedsWake = false;
suppressUntilMs = millis() + 60;
suppressFromMs = millis();
return false;
}
// After a recovery pulse, emit a brief "released" window so gesture state can reset.
if (suppressUntilMs != 0 && millis() < suppressUntilMs) {
if (suppressFromMs != 0 && Throttle::isWithinTimespanMs(suppressFromMs, TOUCH_WAKE_SUPPRESS_MS)) {
return false;
}
#endif
+1 -1
View File
@@ -446,7 +446,7 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke
if (match_request) {
uint32_t start_time = millis();
while (millis() < start_time + 30000) {
while (Throttle::isWithinTimespanMs(start_time, 30000)) {
if (!Bluefruit.connected(conn_handle))
break;
}
+14 -5
View File
@@ -1,4 +1,5 @@
#include "configuration.h"
#include "mesh/Throttle.h"
#include <Adafruit_TinyUSB.h>
#include <Adafruit_nRFCrypto.h>
#include <InternalFileSystem.h>
@@ -270,12 +271,17 @@ namespace
{
constexpr uint8_t NRF52_MAGIC_LFS_IS_CORRUPT = 0xF5;
constexpr uint32_t MULTIPLE_CORRUPTION_DELAY_MILLIS = 20 * 60 * 1000;
static unsigned long millis_until_formatting_again = 0;
// When the last format happened, not when the next one is due: measuring forward from the event
// bounds the pause below by the constant, where a stored deadline could hand delay() any value.
// Armed separately because preFSBegin() runs in the first millisecond of boot, so a zero timestamp
// is a legitimate value here, not an "unset" marker.
static uint32_t last_format_ms = 0;
static bool formatted_this_boot = false;
// Report the critical error from loop(), giving a chance for the screen to be initialized first.
inline void reportLittleFSCorruptionOnce()
{
static bool report_corruption = !!millis_until_formatting_again;
static bool report_corruption = formatted_this_boot;
if (report_corruption) {
report_corruption = false;
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE);
@@ -290,7 +296,8 @@ void preFSBegin()
if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT))
return;
NRF_POWER->GPREGRET = 0;
millis_until_formatting_again = millis() + MULTIPLE_CORRUPTION_DELAY_MILLIS;
last_format_ms = millis();
formatted_this_boot = true;
InternalFS.format();
LOG_INFO("LittleFS format complete; restoring default settings");
}
@@ -298,9 +305,11 @@ void preFSBegin()
extern "C" void lfs_assert(const char *reason)
{
LOG_ERROR("LittleFS corruption detected: %s", reason);
if (millis_until_formatting_again > millis()) {
// Test the armed flag first, since elapsed-since-0 is inside the backoff for the first 20
// minutes after each wrap.
if (formatted_this_boot && Throttle::isWithinTimespanMs(last_format_ms, MULTIPLE_CORRUPTION_DELAY_MILLIS)) {
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE);
const long millis_remain = millis_until_formatting_again - millis();
const long millis_remain = MULTIPLE_CORRUPTION_DELAY_MILLIS - (millis() - last_format_ms);
LOG_WARN("Pausing %d seconds to avoid wear on flash storage", millis_remain / 1000);
delay(millis_remain);
}