Evaluate mesh clock when quorum arrives

This commit is contained in:
mikecarper
2026-07-23 11:20:07 -07:00
parent 468e7846bd
commit abd2d3b7a8
3 changed files with 49 additions and 18 deletions
+16 -11
View File
@@ -610,17 +610,20 @@ send text.flood checking ridge link
- `clock.sync.samples`: `9`
When either source is enabled, the repeater makes its first clock-bootstrap
attempt after 30 minutes of uptime. A successful estimate changes the RTC only
when the absolute difference is **greater than** `clock.sync.drift`; correction
can move the clock forward or backward. A valid estimate within the threshold
counts as a successful sync without changing the clock. Seven days after each
successful estimate, the repeater evaluates time again; the seven-day deadline
therefore starts from the last successful estimate rather than from boot. This
is a lazy uptime deadline: the check runs on the first normal loop/wake after it
becomes due and does not wake the device by itself. If no source or consensus is
available, the repeater retries every 30 minutes until one succeeds, then starts
a new seven-day interval. Every reboot starts with the initial 30-minute attempt.
An existing saved setting always overrides the platform default.
attempt after 30 minutes of uptime, or immediately when the configured number
of fresh evidence sources has been collected, whichever comes first. A
successful estimate changes the RTC only when the absolute difference is
**greater than** `clock.sync.drift`; correction can move the clock forward or
backward. A valid estimate within the threshold counts as a successful sync
without changing the clock. Seven days after each successful estimate, the
repeater evaluates time again; the seven-day deadline therefore starts from the
last successful estimate rather than from boot. This is a lazy uptime deadline:
the check runs on the first normal loop/wake after it becomes due and does not
wake the device by itself. If no source or consensus is available, the repeater
retries every 30 minutes, and newly collected evidence triggers another
immediate evaluation once the configured source count is present. Every reboot
starts with the initial bootstrap attempt. An existing saved setting always
overrides the platform default.
`clock.sync.mesh now` bypasses the startup/seven-day deadline and queues a
LoRa-only consensus evaluation on the next normal loop, even when the internet
@@ -629,6 +632,8 @@ source is also enabled. It uses any currently fresh samples without clearing the
and the next attempt follows the normal 30-minute retry. The command requires
`clock.sync.mesh` to be on and does not bypass CLI, GPS, or NTP suppression; it
also retains the normal quorum, timestamp-validity, and drift checks.
The separate `clock` command only displays the current RTC and does not request
a synchronization attempt.
`clock.sync.mesh` collects signature-verified advert timestamps and MAC-valid,
decrypted Public-channel plain-text timestamps. In normal path mode, collection
+1 -1
View File
@@ -86,7 +86,7 @@ set flood.retry.ignore none
| `flood.channel.scope` | Assigns a transport-region scope to received unscoped floods. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region>`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west` |
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter.1 0x0C all suspend=tempradio` |
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
| `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. After 30 minutes of uptime, it estimates UTC from a configurable consensus of fresh signed-advert or valid Public-channel sources, then repeats lazily seven days after each successful estimate. Failed attempts retry every 30 minutes; `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
| `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
| `clock.sync.mesh.edge` | Defaults on so edge repeaters can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so `repeat off` and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge on` |
| `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same initial and seven-day checks on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet <on|off>` | `set clock.sync.internet on` |
| `clock.sync.drift` | Absolute correction threshold in seconds. The clock is moved forward or backward only when the estimate differs by more than this value. | `get clock.sync.drift`, `set clock.sync.drift <30-86400>` | `set clock.sync.drift 3600` |
+32 -6
View File
@@ -5600,6 +5600,23 @@ void MyMesh::recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[
memcpy(sample.path_id, path_id, sizeof(sample.path_id));
sample.epoch = epoch;
sample.received_millis = received_millis;
// Do not leave a complete quorum waiting behind the startup or retry timer.
// Evaluate on the next normal loop once enough fresh evidence exists. This
// also refreshes a prior no-consensus result whenever a source changes.
if (!clock_sync_complete) {
uint32_t estimate = 0;
uint8_t fresh = 0;
uint8_t agreeing = 0;
uint8_t required = clock_sync_required_samples;
estimateMeshClock(estimate, fresh, agreeing, required);
if (fresh >= clock_sync_required_samples) {
clock_sync_last_fresh_count = fresh;
clock_sync_last_sample_count = agreeing;
clock_sync_last_required_count = required;
clock_sync_next_attempt_uptime = uptime_millis;
}
}
}
void MyMesh::recordAcceptedFloodClockSample(const mesh::Packet* packet) {
@@ -5897,14 +5914,16 @@ void MyMesh::formatClockSyncStatus(const char* args, char* reply, size_t reply_l
return;
}
uint8_t fresh = 0;
uint8_t active = 0;
uint32_t now = _ms->getMillis();
for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) {
if (!clock_sync_samples[i].active) continue;
active++;
if (now - clock_sync_samples[i].received_millis <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) fresh++;
}
uint32_t live_estimate = 0;
uint8_t fresh = 0;
uint8_t live_agreeing = 0;
uint8_t live_required = clock_sync_required_samples;
bool live_consensus = estimateMeshClock(live_estimate, fresh, live_agreeing, live_required);
bool mesh_available = clock_sync_mesh_enabled
&& clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_NONE;
const char* mesh_state = !clock_sync_mesh_enabled ? "off"
@@ -5952,14 +5971,21 @@ void MyMesh::formatClockSyncStatus(const char* args, char* reply, size_t reply_l
collection_active ? "active" : "inactive", mesh_mode,
(unsigned int)active, next_seconds);
} else {
if (clock_sync_last_result == CLOCK_SYNC_RESULT_NO_CONSENSUS) {
if (clock_sync_last_result == CLOCK_SYNC_RESULT_NO_CONSENSUS
|| (fresh >= clock_sync_required_samples && !live_consensus)) {
snprintf(reply, reply_len,
"> not-set reason=no-consensus collect=%s mode=%s %s=%u agree=%u/%u table=%u next=%lus",
collection_active ? "active" : "inactive", mesh_mode, evidence_name,
(unsigned int)fresh,
(unsigned int)clock_sync_last_sample_count,
(unsigned int)clock_sync_last_required_count,
(unsigned int)live_agreeing,
(unsigned int)live_required,
(unsigned int)active, next_seconds);
} else if (live_consensus) {
snprintf(reply, reply_len,
"> not-set reason=ready collect=%s mode=%s %s=%u agree=%u/%u table=%u next=%lus",
collection_active ? "active" : "inactive", mesh_mode, evidence_name,
(unsigned int)fresh, (unsigned int)live_agreeing,
(unsigned int)live_required, (unsigned int)active, next_seconds);
} else {
const char* reason = result;
if (clock_sync_last_result == CLOCK_SYNC_RESULT_WAITING) reason = "waiting-deadline";