From abd2d3b7a8adbea70df44231685ffe92ed48c5a2 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 23 Jul 2026 11:20:07 -0700 Subject: [PATCH] Evaluate mesh clock when quorum arrives --- docs/cli_commands.md | 27 +++++++++++--------- docs/halo_keymind_settings.md | 2 +- examples/simple_repeater/MyMesh.cpp | 38 ++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 74e996c7..745eff6c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index f9a95089..1f834323 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -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] `, `del flood.channel.scope.|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] [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.|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] [path=...]`, `del flood.moderation.|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 `, `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 `, `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 ` | `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 ` | `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` | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 77d6e5ef..5fb5dcc8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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";