From 46d27a014480df81ccaf6b04eb317dc4af8907f2 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:30:53 +0200 Subject: [PATCH] meshtimesync p2 --- zephcore/MESHTIMESYNC.md => MESHTIMESYNC.md | 7 +- zephcore/ARCHITECTURE.md | 4 +- zephcore/Repeater_CLI_commands.md | 4 +- zephcore/app/CompanionMesh.cpp | 40 +----- zephcore/app/CompanionMesh.h | 11 +- zephcore/app/ObserverMesh.cpp | 42 ++---- zephcore/app/ObserverMesh.h | 2 - zephcore/app/RepeaterDataStore.cpp | 2 +- zephcore/app/RepeaterMesh.cpp | 66 +++------ zephcore/app/RepeaterMesh.h | 1 - zephcore/app/RoomServerMesh.cpp | 54 ++----- zephcore/app/RoomServerMesh.h | 8 +- zephcore/helpers/BaseChatMesh.cpp | 7 +- zephcore/helpers/CommonCLI.cpp | 9 +- zephcore/helpers/CommonCLI.h | 6 + zephcore/helpers/MeshTimeSync.cpp | 148 +++++++++++++++----- zephcore/helpers/MeshTimeSync.h | 119 ++++++++-------- 17 files changed, 262 insertions(+), 268 deletions(-) rename zephcore/MESHTIMESYNC.md => MESHTIMESYNC.md (91%) diff --git a/zephcore/MESHTIMESYNC.md b/MESHTIMESYNC.md similarity index 91% rename from zephcore/MESHTIMESYNC.md rename to MESHTIMESYNC.md index 8b34b9d..e71b615 100644 --- a/zephcore/MESHTIMESYNC.md +++ b/MESHTIMESYNC.md @@ -35,7 +35,10 @@ worth watching that dry-run output for a day before (and after) enabling. ## What it will NOT do -- It never overrides GPS time. Nodes with GPS enabled only observe. +- It never fights GPS: a node whose GPS has delivered a real fix in the + last 72 hours only observes. If GPS is on but cannot get a fix (indoors, + dead antenna), mesh correction takes over after 72 hours — until the + next real fix. - It never overrides a recent manual `time ` or `clock sync` — any manual set protects the clock from automatic changes for 7 days. - It never steps more than 1 hour at a time, and at most one step per 6 hours. @@ -81,4 +84,4 @@ adverts take tens of minutes to accumulate after a reboot, so an operator syncing right after login always wins. For the design details (consensus algorithm, security model, why the limits -are what they are), see [ARCHITECTURE.md](ARCHITECTURE.md) section 4.9. +are what they are), see [ARCHITECTURE.md](zephcore/ARCHITECTURE.md) section 4.9. diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 5cc329d..0614f0a 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -303,7 +303,7 @@ ZephCore-only divergence from Arduino MeshCore (like the Adaptive Contention Win **Consensus**: Marzullo interval intersection over eligible votes, each `[skew − r, skew + r]` with `r = 150 s + 15 s × hop` (the 150 s base covers the real fleet's good-clock scatter, not just RF delay). No absolute outlier thresholds against the local clock — clustering does the rejection, so an epoch-reset clock still finds the true cluster. Stepping requires `CONFIG_ZEPHCORE_TIMESYNC_QUORUM` (default 6, floor 3, build-time security knob) eligible senders AND a strict majority inside the intersection; otherwise abstain. **Correction policy** (priority: GPS > manual set > mesh consensus): -- GPS gate: boards with GPS available + enabled never step (sensing continues). +- GPS gate: nodes whose GPS delivered a validated fix within **72 h** never step (covers the repeater's 48 h GPS duty cycle with margin). Only a real fix makes the mesh yield — a GPS that is enabled but cannot fix (indoors, dead antenna) stops gating after the window, so those units stay mesh-correctable. Sensing always continues; the dry-run marks refused steps `(gps-gated)`. - Manual set (`time`, `clock sync`, app time set) arms a **7-day suppression** of all stepping, bootstrap included, plus drift-envelope pedigree. - Step trigger 10 min, dead band 5 min, step capped **±1 h**, one step per **6 h**, logged loudly. Production contains coherent wrong-time islands (+28 h × 63 repeaters at analysis time); the cap bounds capture drag to 4 h/day. - **Drift-envelope gate**: with a trusted sync + continuous uptime since (pedigree, RAM-only), corrections beyond `elapsed × 300 ppm + 10 min` are physically impossible for a crystal — refused regardless of quorum. @@ -317,7 +317,7 @@ ZephCore-only divergence from Arduino MeshCore (like the Adaptive Contention Win | Room server | forward-only | post timestamps feed client `sync_since` ordering | | Companion | forward-only | own clock stamps outgoing DMs; peers hold per-sender replay high-water marks | -**Step application** (repeater reference, `applyTimeSyncStep`): set clock, one `zephcore_rtc_save` per step (never per evaluation), shift neighbor `heard_timestamp`s and ACL `last_activity` by the delta (unsigned "seconds ago" math), reset the login/anon/discover rate limiters. +**Step application**: the shared policy (GPS gate, forward-only skip, uint32-overflow guard, set clock, one `zephcore_rtc_save` per step — never per evaluation) lives in `MeshTimeSync::runTick()`; when it returns true, the role shifts its wall-clock-anchored bookkeeping by `lastStepDelta()` — repeater: neighbor `heard_timestamp`s, ACL `last_activity`, login/anon/discover rate-limiter resets; room server: ACL + login limiter. All policy timers (6 h rate limit, 7-day suppression, tenure, sample age) anchor on **uptime, never wall clock** — otherwise the very steps they govern would distort them. diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 0671b88..f555969 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -206,7 +206,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get radio.rxgain` | RX gain boost: `0` or `1` | | `get rxduty` | RX duty cycle mode: `0` or `1` | | `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | -| `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = tenure-eligible). Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | +| `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; steps the policy would refuse are annotated `(gps-gated)` or `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = tenure-eligible). Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | | `get dc.restarts` | Duty-cycle preamble false-positive re-arm counter (RxTimeout re-arms + parked-RX watchdog recoveries). High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery; packets are never lost to it. Reset by `clear stats`. | | `get adc.multiplier` | Battery voltage ADC calibration multiplier | | `get bootloader.ver` | Bootloader version string | @@ -251,7 +251,7 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost, applied live. Replies `Error: unsupported` on radios without RX boost (SX127x); the pref is still saved. | | `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | | `set adc.multiplier ` | (0 = use board default) | Battery voltage ADC calibration multiplier | -| `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides GPS or a manual set less than 7 days old. See `MESHTIMESYNC.md`. | +| `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides a GPS clock with a validated fix in the last 72 h, or a manual set less than 7 days old. See `MESHTIMESYNC.md`. | | `set prv.key ` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* | --- diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index eef252b..a4074c5 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -353,43 +353,9 @@ void CompanionMesh::onAdvertTimeSample(const mesh::Identity &id, uint32_t timest void CompanionMesh::timeSyncTick() { if (!prefs.meshtimesync) return; - - uint32_t up = (uint32_t)(k_uptime_get() / 1000); - uint32_t now = getRTCClock()->getCurrentTime(); - MeshTimeSync::Verdict v = _timesync.tick(now, up); - if (v.type != MeshTimeSync::VERDICT_STEP) return; - - /* GPS gate: sense only while GPS owns the clock. */ - if (gps_is_available() && gps_is_enabled()) { - LOG_INF("meshtimesync: step %+d s wanted, GPS gate active - not applied", - (int)v.delta); - return; - } - /* Forward-only role: our clock stamps outgoing DMs and peers hold - * per-sender replay high-water marks — a backward step gets our - * messages dropped as replays. Report, never apply. */ - if (v.delta < 0) { - _timesync.noteBackwardSkipped(); - LOG_WRN("meshtimesync: backward step %+d s wanted - skipped (companion is forward-only)", - (int)v.delta); - return; - } - applyTimeSyncStep(v, now, up); -} - -void CompanionMesh::applyTimeSyncStep(const MeshTimeSync::Verdict &v, uint32_t now, - uint32_t uptime_secs) -{ - uint32_t new_time = (uint32_t)((int64_t)now + v.delta); - getRTCClock()->setCurrentTime(new_time); - zephcore_rtc_save(new_time); - time_sync_report(TIME_SYNC_MESH); - - _timesync.noteStepApplied(v.delta, new_time, uptime_secs, v.bootstrap); - LOG_WRN("meshtimesync: stepped clock %+d s (%s, votes %u/%u) -> %u", - (int)v.delta, v.bootstrap ? "bootstrap" : "consensus", - (unsigned)v.consensus.votes_for, (unsigned)v.consensus.votes_against, - (unsigned)new_time); + /* Shared policy (GPS fix-freshness gate, forward-only) lives in + * runTick; no companion-side bookkeeping needs shifting on a step. */ + _timesync.runTick(*getRTCClock()); } void CompanionMesh::onLoginSent(const ContactInfo &contact) diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index ae58c54..e292a3a 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -233,9 +233,7 @@ public: bool onChannelLoaded(uint8_t idx, const ChannelDetails &ch) override; bool getChannelForSave(uint8_t idx, ChannelDetails &ch) override; - /* Mesh time sync (forward-only: our clock stamps outgoing DMs and peers - * hold per-sender replay high-water marks, so a backward step gets our - * messages dropped as replays until wall-clock catches up) */ + /* Mesh time sync */ MeshTimeSync *getMeshTimeSync() { return &_timesync; } void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } /* Paced evaluation — called from the housekeeping event (loop() only runs @@ -380,12 +378,11 @@ private: void flushDirtyContacts(); void flushDirtyChannels(); - /* Mesh time sync */ - MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; + /* Mesh time sync (forward-only: our clock stamps outgoing DMs and peers + * hold per-sender replay high-water marks) */ + MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH, true}; void onAdvertTimeSample(const mesh::Identity &id, uint32_t timestamp, uint8_t hops) override; - void applyTimeSyncStep(const MeshTimeSync::Verdict &v, uint32_t now, - uint32_t uptime_secs); /* Protocol version negotiation */ uint8_t _app_target_ver; diff --git a/zephcore/app/ObserverMesh.cpp b/zephcore/app/ObserverMesh.cpp index d809a0b..1ccd973 100644 --- a/zephcore/app/ObserverMesh.cpp +++ b/zephcore/app/ObserverMesh.cpp @@ -9,8 +9,6 @@ #include #include #include -#include -#include #include #include @@ -230,6 +228,12 @@ void ObserverMesh::harvestTimeSample(Packet *pkt) if (pkt->getPayloadType() != PAYLOAD_TYPE_ADVERT) return; if (pkt->getPathHashCount() > MeshTimeSync::HOP_CAP) return; if (pkt->payload_len < PUB_KEY_SIZE + 4 + SIGNATURE_SIZE) return; + /* Skip share rebroadcasts (transport codes {0,0}) — they replay stale + * stored adverts and would churn the original sender's tenure. */ + if (pkt->hasTransportCodes() && + pkt->transport_codes[0] == 0 && pkt->transport_codes[1] == 0) { + return; + } int i = 0; Identity id; @@ -241,6 +245,10 @@ void ObserverMesh::harvestTimeSample(Packet *pkt) const uint8_t *signature = &pkt->payload[i]; i += SIGNATURE_SIZE; + /* Observers have no dedup and hear every flood copy — skip the + * expensive Ed25519 verify when the sample cannot update the table. */ + if (!_timesync.wouldAccept(id.pub_key, timestamp)) return; + size_t app_data_len = pkt->payload_len - (size_t)i; if (app_data_len > MAX_ADVERT_DATA_SIZE) app_data_len = MAX_ADVERT_DATA_SIZE; @@ -259,19 +267,9 @@ void ObserverMesh::harvestTimeSample(Packet *pkt) void ObserverMesh::timeSyncTick() { if (!_prefs.meshtimesync || !_rtc) return; - - uint32_t up = (uint32_t)(k_uptime_get() / 1000); - uint32_t now = _rtc->getCurrentTime(); - MeshTimeSync::Verdict v = _timesync.tick(now, up); - if (v.type != MeshTimeSync::VERDICT_STEP) return; - - /* GPS gate: sense only while GPS owns the clock. */ - if (gps_is_available() && gps_is_enabled()) { - LOG_INF("meshtimesync: step %+d s wanted, GPS gate active - not applied", - (int)v.delta); - return; - } - applyTimeSyncStep(v, now, up); + /* Shared policy (GPS fix-freshness gate) lives in runTick; no + * observer-side bookkeeping needs shifting on a step. */ + _timesync.runTick(*_rtc); } void ObserverMesh::noteTrustedTimeSync() @@ -279,20 +277,6 @@ void ObserverMesh::noteTrustedTimeSync() _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); } -void ObserverMesh::applyTimeSyncStep(const MeshTimeSync::Verdict &v, uint32_t now, - uint32_t uptime_secs) -{ - uint32_t new_time = (uint32_t)((int64_t)now + v.delta); - _rtc->setCurrentTime(new_time); - zephcore_rtc_save(new_time); - - _timesync.noteStepApplied(v.delta, new_time, uptime_secs, v.bootstrap); - LOG_WRN("meshtimesync: stepped clock %+d s (%s, votes %u/%u) -> %u", - (int)v.delta, v.bootstrap ? "bootstrap" : "consensus", - (unsigned)v.consensus.votes_for, (unsigned)v.consensus.votes_against, - (unsigned)new_time); -} - /* ========== Serial CLI ========== */ #define CLI_REPLY_SIZE 256 diff --git a/zephcore/app/ObserverMesh.h b/zephcore/app/ObserverMesh.h index c311bee..10eed5b 100644 --- a/zephcore/app/ObserverMesh.h +++ b/zephcore/app/ObserverMesh.h @@ -63,8 +63,6 @@ class ObserverMesh : public Dispatcher { * advert signatures itself before harvesting. */ MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; void harvestTimeSample(Packet *pkt); - void applyTimeSyncStep(const MeshTimeSync::Verdict &v, uint32_t now, - uint32_t uptime_secs); protected: /* Capture RSSI + raw bytes before packet is parsed */ diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 3293df5..308643e 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -243,7 +243,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { prefs.path_hash_mode = 1; prefs.loop_detect = LOOP_DETECT_MODERATE; savePrefs(prefs); - LOG_INF("loadPrefs: upgraded prefs format (%d -> 296 bytes)", (int)entry.size); + LOG_INF("loadPrefs: upgraded prefs format (%d -> 297 bytes)", (int)entry.size); } /* Repeater GPS-interval unification migration: before this firmware the diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index db5da08..792dcee 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -12,8 +12,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,10 +50,14 @@ LOG_MODULE_REGISTER(zephcore_repeater, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) static RepeaterMesh *s_uplink_mesh; +/* Runs on the WiFi thread; the mesh time-sync module is main-thread-only, so + * flag the trusted sync and let loop() arm suppression + drift envelope. */ +static atomic_t s_uplink_sntp_pending; static void uplink_time_sync_cb(uint32_t unix_ts) { if (s_uplink_mesh) { s_uplink_mesh->getRTCClock()->setCurrentTime(unix_ts); + atomic_set(&s_uplink_sntp_pending, 1); } } #endif @@ -713,9 +715,13 @@ void RepeaterMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, const uint8_t* app_data, size_t app_data_len) { mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); - /* Signature already verified by mesh::Mesh before this hook fires. */ - _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), - (uint32_t)(k_uptime_get() / 1000)); + /* Signature already verified by mesh::Mesh before this hook fires. + * Skip share rebroadcasts — they replay stale stored adverts and would + * churn the original sender's tenure. */ + if (!isShare(packet)) { + _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), + (uint32_t)(k_uptime_get() / 1000)); + } if (packet->getPathHashCount() == 0 && !isShare(packet)) { AdvertDataParser parser(app_data, app_data_len); @@ -790,7 +796,7 @@ void RepeaterMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender } } - uint8_t temp[166]; + uint8_t temp[5 + CLI_REMOTE_REPLY_SIZE]; char* command = (char*)&data[5]; char* reply = (char*)&temp[5]; if (is_retry) { @@ -1378,6 +1384,10 @@ void RepeaterMesh::loop() { publishUplinkStatus("online"); _uplink_next_status_at = futureMillis(300000); } + if (atomic_cas(&s_uplink_sntp_pending, 1, 0)) { + /* SNTP set the clock (trusted) — arm suppression + drift envelope. */ + _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); + } #endif timeSyncTick(); @@ -1389,47 +1399,23 @@ void RepeaterMesh::loop() { void RepeaterMesh::timeSyncTick() { if (!_prefs.meshtimesync) return; + if (!_timesync.runTick(*getRTCClock())) return; - uint32_t up = (uint32_t)(k_uptime_get() / 1000); - uint32_t now = getRTCClock()->getCurrentTime(); - MeshTimeSync::Verdict v = _timesync.tick(now, up); - if (v.type != MeshTimeSync::VERDICT_STEP) { - if (v.type == MeshTimeSync::VERDICT_ABSTAIN) { - LOG_DBG("meshtimesync: abstain (%s)", MeshTimeSync::reasonStr(v.reason)); - } - return; - } - /* GPS gate: GPS sets the clock unconditionally — a wrong mesh step - * followed by a GPS step-back would poison our own advert high-water - * marks at peers. Sense only. */ - if (gps_is_available() && gps_is_enabled()) { - LOG_INF("meshtimesync: step %+d s wanted, GPS gate active - not applied", - (int)v.delta); - return; - } - applyTimeSyncStep(v, now, up); -} - -void RepeaterMesh::applyTimeSyncStep(const MeshTimeSync::Verdict& v, uint32_t now, - uint32_t uptime_secs) { - uint32_t new_time = (uint32_t)((int64_t)now + v.delta); - getRTCClock()->setCurrentTime(new_time); - zephcore_rtc_save(new_time); - time_sync_report(TIME_SYNC_MESH); - - /* Wall-clock-anchored bookkeeping must move with the step, or a backward - * step underflows the unsigned "seconds ago" math. 0 = unset sentinel. */ + /* Step applied — wall-clock-anchored bookkeeping must move with it, or a + * backward step underflows the unsigned "seconds ago" math. 0 = unset + * sentinel. */ + int64_t delta = _timesync.lastStepDelta(); #if MAX_NEIGHBOURS > 0 for (int i = 0; i < MAX_NEIGHBOURS; i++) { if (neighbours[i].heard_timestamp == 0) continue; - int64_t shifted = (int64_t)neighbours[i].heard_timestamp + v.delta; + int64_t shifted = (int64_t)neighbours[i].heard_timestamp + delta; neighbours[i].heard_timestamp = (shifted > 0) ? (uint32_t)shifted : 1; } #endif for (int i = 0; i < acl.getNumClients(); i++) { ClientInfo* c = acl.getClientByIdx(i); if (c->last_activity == 0) continue; - int64_t shifted = (int64_t)c->last_activity + v.delta; + int64_t shifted = (int64_t)c->last_activity + delta; c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; } /* A backward step would otherwise wedge these shut until wall-clock @@ -1437,12 +1423,6 @@ void RepeaterMesh::applyTimeSyncStep(const MeshTimeSync::Verdict& v, uint32_t no discover_limiter.reset(); anon_limiter.reset(); login_fail_limiter.reset(); - - _timesync.noteStepApplied(v.delta, new_time, uptime_secs, v.bootstrap); - LOG_WRN("meshtimesync: stepped clock %+d s (%s, votes %u/%u) -> %u", - (int)v.delta, v.bootstrap ? "bootstrap" : "consensus", - (unsigned)v.consensus.votes_for, (unsigned)v.consensus.votes_against, - (unsigned)new_time); } bool RepeaterMesh::hasPendingWork() const { diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index 4f54106..448d6c7 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -134,7 +134,6 @@ class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks { void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); void timeSyncTick(); - void applyTimeSyncStep(const MeshTimeSync::Verdict& v, uint32_t now, uint32_t uptime_secs); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 75f63a8..b567776 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -19,8 +19,6 @@ #include #include #include -#include -#include #include #include #include @@ -579,7 +577,7 @@ void RoomServerMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int send mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 5 + strlen((char*)&data[5]), client->id.pub_key, PUB_KEY_SIZE); - uint8_t temp[166]; + uint8_t temp[5 + CLI_REMOTE_REPLY_SIZE]; bool send_ack; if (flags == TXT_TYPE_CLI_DATA) { // admin CLI over the air if (client->isAdmin()) { @@ -1117,57 +1115,31 @@ void RoomServerMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { (void)app_data; (void)app_data_len; - /* Signature already verified by mesh::Mesh before this hook fires. */ + /* Signature already verified by mesh::Mesh before this hook fires. + * Skip share rebroadcasts (transport codes {0,0}) — they replay stale + * stored adverts and would churn the original sender's tenure. */ + if (packet->hasTransportCodes() && + packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0) { + return; + } _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), (uint32_t)(k_uptime_get() / 1000)); } void RoomServerMesh::timeSyncTick() { if (!_prefs.meshtimesync) return; + if (!_timesync.runTick(*getRTCClock())) return; - uint32_t up = (uint32_t)(k_uptime_get() / 1000); - uint32_t now = getRTCClock()->getCurrentTime(); - MeshTimeSync::Verdict v = _timesync.tick(now, up); - if (v.type != MeshTimeSync::VERDICT_STEP) return; - - /* GPS gate: sense only while GPS owns the clock. */ - if (gps_is_available() && gps_is_enabled()) { - LOG_INF("meshtimesync: step %+d s wanted, GPS gate active - not applied", - (int)v.delta); - return; - } - /* Forward-only role: post timestamps feed client sync_since ordering, - * so a backward step corrupts message sync. Report, never apply. */ - if (v.delta < 0) { - _timesync.noteBackwardSkipped(); - LOG_WRN("meshtimesync: backward step %+d s wanted - skipped (room server is forward-only)", - (int)v.delta); - return; - } - applyTimeSyncStep(v, now, up); -} - -void RoomServerMesh::applyTimeSyncStep(const MeshTimeSync::Verdict& v, uint32_t now, - uint32_t uptime_secs) { - uint32_t new_time = (uint32_t)((int64_t)now + v.delta); - getRTCClock()->setCurrentTime(new_time); - zephcore_rtc_save(new_time); - time_sync_report(TIME_SYNC_MESH); - - /* Wall-clock-anchored bookkeeping moves with the step (see RepeaterMesh). */ + /* Step applied (forward-only enforced in runTick) — shift wall-clock- + * anchored bookkeeping with it. */ + int64_t delta = _timesync.lastStepDelta(); for (int i = 0; i < acl.getNumClients(); i++) { ClientInfo* c = acl.getClientByIdx(i); if (c->last_activity == 0) continue; - int64_t shifted = (int64_t)c->last_activity + v.delta; + int64_t shifted = (int64_t)c->last_activity + delta; c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; } login_fail_limiter.reset(); - - _timesync.noteStepApplied(v.delta, new_time, uptime_secs, v.bootstrap); - LOG_WRN("meshtimesync: stepped clock %+d s (%s, votes %u/%u) -> %u", - (int)v.delta, v.bootstrap ? "bootstrap" : "consensus", - (unsigned)v.consensus.votes_for, (unsigned)v.consensus.votes_against, - (unsigned)new_time); } bool RoomServerMesh::hasPendingWork() const { diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index d7354a0..bd33813 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -95,12 +95,13 @@ class RoomServerMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t pending_sf; uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; - MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; + /* Forward-only: post timestamps feed client sync_since ordering, so a + * backward step would corrupt message sync. */ + MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH, true}; int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); mesh::Packet* createSelfAdvert(); void timeSyncTick(); - void applyTimeSyncStep(const MeshTimeSync::Verdict& v, uint32_t now, uint32_t uptime_secs); /* Room server: shared-post buffer + push-to-client sync */ void addPost(ClientInfo* client, const char* postData); @@ -197,8 +198,7 @@ public: void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; - /* Mesh time sync (forward-only: post timestamps feed client sync_since - * ordering, so a backward step would corrupt message sync) */ + /* Mesh time sync */ MeshTimeSync* getMeshTimeSync() override { return &_timesync; } void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index f0e4042..8b4fb26 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -139,7 +139,12 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, { LOG_DBG("onAdvertRecv: timestamp=%u app_data_len=%u", timestamp, (unsigned)app_data_len); - onAdvertTimeSample(id, timestamp, packet->getPathHashCount()); + /* Time-sample hook skips share rebroadcasts (transport codes {0,0}) — + * they replay stale stored adverts and would churn the sender's tenure. */ + if (!(packet->hasTransportCodes() && + packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0)) { + onAdvertTimeSample(id, timestamp, packet->getPathHashCount()); + } AdvertDataParser parser(app_data, app_data_len); if (!(parser.isValid() && parser.hasName())) { diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 41f757b..d80b372 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -551,10 +551,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch if (ts == nullptr) { strcpy(reply, "not available"); } else { - /* Remote replies ride in a ~160-byte packet buffer; only the - * local USB CLI (sender_timestamp == 0) gets the full evidence - * table. */ - size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE : 158; + /* Only the local USB CLI (sender_timestamp == 0) gets the + * full evidence table; remote replies are truncated to the + * packet buffer. */ + size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE + : CLI_REMOTE_REPLY_SIZE; ts->formatStatus(reply, cap, getRTCClock()->getCurrentTime(), (uint32_t)(k_uptime_get() / 1000), _prefs->meshtimesync != 0); diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 99ad211..8667288 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -17,6 +17,12 @@ class MeshTimeSync; /* CLI reply buffer size — callers must provide at least this many bytes */ #define CLI_REPLY_SIZE 256 +/* Remote-admin replies ride in the caller's LoRa packet buffer: + * RepeaterMesh/RoomServerMesh onPeerDataRecv declare temp[5 + this] with the + * reply text at offset 5. Handlers that can exceed this must self-limit + * whenever sender_timestamp != 0 (0 marks the local USB CLI). */ +#define CLI_REMOTE_REPLY_SIZE 161 + /* Deferred reboot types */ #define REBOOT_NONE 0 #define REBOOT_NORMAL 1 diff --git a/zephcore/helpers/MeshTimeSync.cpp b/zephcore/helpers/MeshTimeSync.cpp index e2c741e..15dced8 100644 --- a/zephcore/helpers/MeshTimeSync.cpp +++ b/zephcore/helpers/MeshTimeSync.cpp @@ -6,13 +6,42 @@ #include "MeshTimeSync.h" +#include +#include +#include +#include +#include #include #include -void MeshTimeSync::reset(uint32_t build_epoch) +LOG_MODULE_REGISTER(zephcore_timesync, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +/* Clamp an int64 skew to a printable long (display only). */ +static long clampl(int64_t v) +{ + if (v > 2000000000LL) return 2000000000L; + if (v < -2000000000LL) return -2000000000L; + return (long)v; +} + +/* GPS gate: only a validated fix younger than GPS_FIX_FRESH_SECS makes the + * mesh yield — a GPS that is enabled but cannot fix (indoors, dead antenna) + * stops gating after the window, so those units stay mesh-correctable. */ +static bool gps_gate_active(void) +{ + if (!gps_is_available() || !gps_is_enabled()) { + return false; + } + struct gps_state_info info; + gps_get_state_info(&info); + return info.last_fix_age_s < MeshTimeSync::GPS_FIX_FRESH_SECS; +} + +void MeshTimeSync::reset(uint32_t build_epoch, bool forward_only) { memset(_slots, 0, sizeof(_slots)); _build_epoch = build_epoch; + _forward_only = forward_only; _next_eval_uptime = 0; _suppress_uptime = 0; _suppressed = false; @@ -22,7 +51,6 @@ void MeshTimeSync::reset(uint32_t build_epoch) _stepped_once = false; _evals = _abstains = _steps = _bootstrap_steps = _backward_skips = 0; _last_step_delta = 0; - _last_step_wall = 0; } MeshTimeSync::Slot *MeshTimeSync::findSlot(const uint8_t *prefix) @@ -55,6 +83,17 @@ int64_t MeshTimeSync::slotSkew(const Slot &s, uint32_t local_time, uint32_t upti (int64_t)local_time; } +bool MeshTimeSync::wouldAccept(const uint8_t *pubkey, uint32_t advert_ts) const +{ + for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) { + const Slot &s = _slots[i]; + if (s.used && memcmp(s.prefix, pubkey, sizeof(s.prefix)) == 0) { + return advert_ts > s.advert_ts; + } + } + return true; +} + void MeshTimeSync::onAdvertHeard(const uint8_t *pubkey, uint32_t advert_ts, uint8_t hops, uint32_t uptime_secs) { @@ -125,19 +164,22 @@ MeshTimeSync::Consensus MeshTimeSync::computeConsensus(uint32_t local_time, Consensus c; memset(&c, 0, sizeof(c)); - /* Collect eligible votes. Bootstrap relaxes tenure: any sender with a - * fresh sample may vote (the table is RAM-only, so after the reboot that - * dead-ended the clock everything in it is freshly heard anyway). */ - int64_t skews[MESHTIMESYNC_TABLE_SIZE]; - int32_t radii[MESHTIMESYNC_TABLE_SIZE]; - int n = 0; + /* Collect eligible votes directly as Marzullo interval endpoints. + * Bootstrap relaxes tenure: any sender with a fresh sample may vote + * (the table is RAM-only, so after the reboot that dead-ended the + * clock everything in it is freshly heard anyway). */ + int64_t val[2 * MESHTIMESYNC_TABLE_SIZE]; + int8_t typ[2 * MESHTIMESYNC_TABLE_SIZE]; /* +1 = start, -1 = end */ + int n = 0, m = 0; for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) { const Slot &s = _slots[i]; if (!s.used) continue; if ((uptime_secs - s.arrival_uptime) > MAX_SAMPLE_AGE_SECS) continue; if (!bootstrap && !slotTenured(s, uptime_secs)) continue; - skews[n] = slotSkew(s, local_time, uptime_secs); - radii[n] = RADIUS_BASE_SECS + RADIUS_PER_HOP_SECS * s.hops; + int64_t skew = slotSkew(s, local_time, uptime_secs); + int32_t r = RADIUS_BASE_SECS + RADIUS_PER_HOP_SECS * s.hops; + val[m] = skew - r; typ[m] = 1; m++; + val[m] = skew + r; typ[m] = -1; m++; n++; } c.eligible = (uint8_t)n; @@ -145,15 +187,8 @@ MeshTimeSync::Consensus MeshTimeSync::computeConsensus(uint32_t local_time, /* Marzullo endpoint sweep — interval intersection with the most votes. * No absolute outlier thresholds against our own clock: clustering does - * the rejection, so an epoch-0 local clock still finds the true cluster. */ - int64_t val[2 * MESHTIMESYNC_TABLE_SIZE]; - int8_t typ[2 * MESHTIMESYNC_TABLE_SIZE]; /* +1 = start, -1 = end */ - int m = 0; - for (int i = 0; i < n; i++) { - val[m] = skews[i] - radii[i]; typ[m] = 1; m++; - val[m] = skews[i] + radii[i]; typ[m] = -1; m++; - } - /* Insertion sort by value; starts before ends at equal values so + * the rejection, so an epoch-0 local clock still finds the true cluster. + * Insertion sort by value; starts before ends at equal values so * touching intervals count as overlapping. */ for (int i = 1; i < m; i++) { int64_t v = val[i]; @@ -301,6 +336,50 @@ MeshTimeSync::Verdict MeshTimeSync::tick(uint32_t local_time, uint32_t uptime_se return v; } +bool MeshTimeSync::runTick(mesh::RTCClock &rtc) +{ + uint32_t up = (uint32_t)(k_uptime_get() / 1000); + uint32_t now = rtc.getCurrentTime(); + Verdict v = tick(now, up); + if (v.type != VERDICT_STEP) { + if (v.type == VERDICT_ABSTAIN) { + LOG_DBG("abstain (%s)", reasonStr(v.reason)); + } + return false; + } + if (gps_gate_active()) { + LOG_INF("step %+ld s wanted, GPS fix is fresh - not applied", + clampl(v.delta)); + return false; + } + if (_forward_only && v.delta < 0) { + /* Forward-only roles: post timestamps feed client sync_since + * ordering (room server) / peers hold per-sender replay high-water + * marks for our DMs (companion). Report, never apply. */ + _backward_skips++; + LOG_WRN("backward step %+ld s wanted - skipped (forward-only role)", + clampl(v.delta)); + return false; + } + int64_t nt = (int64_t)now + v.delta; + if (nt <= 0 || nt > (int64_t)UINT32_MAX) { + /* Would wrap the uint32 clock — only reachable with a garbage + * bootstrap cluster near the timestamp ceiling. */ + LOG_WRN("implausible step %+ld s refused", clampl(v.delta)); + return false; + } + uint32_t new_time = (uint32_t)nt; + rtc.setCurrentTime(new_time); + zephcore_rtc_save(new_time); + time_sync_report(TIME_SYNC_MESH); + noteStepApplied(v.delta, up, v.bootstrap); + LOG_WRN("stepped clock %+ld s (%s, votes %u/%u) -> %u", + clampl(v.delta), v.bootstrap ? "bootstrap" : "consensus", + (unsigned)v.consensus.votes_for, (unsigned)v.consensus.votes_against, + (unsigned)new_time); + return true; +} + void MeshTimeSync::noteManualSync(uint32_t uptime_secs) { _suppress_uptime = uptime_secs; @@ -315,15 +394,13 @@ void MeshTimeSync::noteGPSSync(uint32_t uptime_secs) _pedigree = true; } -void MeshTimeSync::noteStepApplied(int64_t delta, uint32_t local_time, - uint32_t uptime_secs, bool bootstrap) +void MeshTimeSync::noteStepApplied(int64_t delta, uint32_t uptime_secs, bool bootstrap) { _last_step_uptime = uptime_secs; _stepped_once = true; _steps++; if (bootstrap) _bootstrap_steps++; _last_step_delta = delta; - _last_step_wall = local_time; } bool MeshTimeSync::isSuppressed(uint32_t uptime_secs) const @@ -351,24 +428,24 @@ const char *MeshTimeSync::reasonStr(Reason r) } } -/* Clamp an int64 skew to a printable long (display only). */ -static long clampl(int64_t v) -{ - if (v > 2000000000LL) return 2000000000L; - if (v < -2000000000LL) return -2000000000L; - return (long)v; -} - int MeshTimeSync::formatStatus(char *out, size_t cap, uint32_t local_time, uint32_t uptime_secs, bool enabled) const { Verdict v = evaluateNow(local_time, uptime_secs); const Consensus &c = v.consensus; - char verdict[40]; + char verdict[56]; if (v.type == VERDICT_STEP) { - snprintf(verdict, sizeof(verdict), "step%+ld%s", clampl(v.delta), - v.bootstrap ? " (bootstrap)" : ""); + /* Annotate steps the shared step policy would refuse, so the + * dry-run never claims a step that will not happen. */ + const char *note = ""; + if (gps_gate_active()) { + note = " (gps-gated)"; + } else if (_forward_only && v.delta < 0) { + note = " (skipped: forward-only)"; + } + snprintf(verdict, sizeof(verdict), "step%+ld%s%s", clampl(v.delta), + v.bootstrap ? " (bootstrap)" : "", note); } else if (v.type == VERDICT_NONE && v.reason == REASON_IN_BAND) { int64_t mag = c.mid < 0 ? -c.mid : c.mid; snprintf(verdict, sizeof(verdict), "%s", @@ -391,8 +468,9 @@ int MeshTimeSync::formatStatus(char *out, size_t cap, uint32_t local_time, if (pos < 0 || (size_t)pos >= cap) goto full; if (_steps > 0) { - pos += snprintf(out + pos, cap - pos, "; steps=%lu last=%+lds", - (unsigned long)_steps, clampl(_last_step_delta)); + pos += snprintf(out + pos, cap - pos, "; steps=%lu boot=%lu last=%+lds", + (unsigned long)_steps, (unsigned long)_bootstrap_steps, + clampl(_last_step_delta)); if (pos < 0 || (size_t)pos >= cap) goto full; } if (_backward_skips > 0) { diff --git a/zephcore/helpers/MeshTimeSync.h b/zephcore/helpers/MeshTimeSync.h index 51e88cc..6779e78 100644 --- a/zephcore/helpers/MeshTimeSync.h +++ b/zephcore/helpers/MeshTimeSync.h @@ -2,21 +2,25 @@ * SPDX-License-Identifier: MIT * MeshTimeSync - mesh clock consensus from Ed25519-signed advert timestamps. * - * Role-agnostic estimator: owns no clock. Each role feeds it signature- - * verified adverts (onAdvertHeard), calls tick() from its loop, and applies - * STEP verdicts under its own step policy (repeater/observer: bidirectional; - * room server/companion: forward-only; GPS-synced boards: sense only). - * ZephCore-only divergence from Arduino MeshCore — design rationale in - * ARCHITECTURE.md, user-facing doc in MESHTIMESYNC.md. + * Role-agnostic estimator plus shared step policy. Each role feeds it + * signature-verified adverts (onAdvertHeard) and calls runTick() from its + * loop; runTick applies the shared policy (GPS fix-freshness gate, + * forward-only roles skip backward steps, ±1 h cap, 6 h rate limit) and + * steps the given clock. Role-specific bookkeeping (neighbor/ACL timestamp + * shifts, rate-limiter resets) happens in the role after runTick returns + * true. ZephCore-only divergence from Arduino MeshCore — design rationale + * in ARCHITECTURE.md, user-facing doc in MESHTIMESYNC.md. * * All policy timers anchor on uptime, never wall clock, so the very steps - * they govern cannot distort them. + * they govern cannot distort them. Main-thread-only, like the mesh classes + * that own it. */ #pragma once #include #include +#include #ifdef CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE #define MESHTIMESYNC_TABLE_SIZE CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE @@ -56,6 +60,12 @@ public: static constexpr int64_t PEDIGREE_PPM = 300; static constexpr int64_t PEDIGREE_BASE_SECS = 10 * 60; static constexpr uint8_t BOOTSTRAP_QUORUM = 3; + /* GPS gate: a validated fix younger than this means GPS owns the clock + * (it re-syncs and would step-back a wrong mesh step, poisoning our + * advert high-water marks at peers). Covers the repeater's 48 h GPS + * duty cycle with margin; a unit whose GPS cannot get a fix (indoors, + * dead antenna) becomes mesh-correctable after this window. */ + static constexpr uint32_t GPS_FIX_FRESH_SECS = 72 * 3600; /* 8-byte prefix is a security floor, not a tuning knob: it is the * sender's identity for tenure/votes while signatures verify the full @@ -104,78 +114,74 @@ public: Consensus consensus; }; - explicit MeshTimeSync(uint32_t build_epoch = 0) { reset(build_epoch); } + explicit MeshTimeSync(uint32_t build_epoch = 0, bool forward_only = false) + { + reset(build_epoch, forward_only); + } - void reset(uint32_t build_epoch); + void reset(uint32_t build_epoch, bool forward_only); /* Feed one signature-verified advert. hops = flood path length (0 = heard - * direct). Samples beyond HOP_CAP are dropped. */ + * direct). Samples beyond HOP_CAP are dropped. Callers must not feed + * share rebroadcasts (transport codes {0,0}) — those replay stale stored + * adverts and would churn the sender's tenure. */ void onAdvertHeard(const uint8_t *pubkey, uint32_t advert_ts, uint8_t hops, uint32_t uptime_secs); - /* Paced evaluation — returns VERDICT_NONE/REASON_NONE unless - * EVAL_INTERVAL_SECS elapsed since the last real evaluation. The caller - * applies STEP verdicts under its role policy and reports the outcome - * via noteStepApplied() (the step rate limit counts applied steps only). */ - Verdict tick(uint32_t local_time, uint32_t uptime_secs); + /* Cheap pre-check: would onAdvertHeard even consider this sample? + * Lets callers skip the Ed25519 verify for per-sender duplicates + * (observers hear every flood copy with no dedup of their own). */ + bool wouldAccept(const uint8_t *pubkey, uint32_t advert_ts) const; - /* Unpaced consensus computation (CLI dry-run view). */ - Consensus computeConsensus(uint32_t local_time, uint32_t uptime_secs, - bool bootstrap) const; + /* Paced policy run — call from the role's loop with its RTC clock. + * Evaluates at most every EVAL_INTERVAL_SECS; applies the shared step + * policy and steps the clock (incl. hardware-RTC save + UI time-source + * report). Returns true when a step was applied — the role then shifts + * its wall-clock-anchored bookkeeping by lastStepDelta(). The caller + * gates on its own enable pref. */ + bool runTick(mesh::RTCClock &rtc); - /* Unpaced full policy evaluation (no counter/pacing side effects) — - * what tick() would decide right now. Used by the CLI dry-run. */ - Verdict evaluateNow(uint32_t local_time, uint32_t uptime_secs) const; - - bool isBootstrap(uint32_t local_time) const { return local_time < _build_epoch; } - - /* Manual clock set (CLI time/clock sync, app time set): arms the 7-day - * suppression window AND drift-envelope pedigree. Suppression gates - * bootstrap too. */ + /* Manual clock set (CLI time/clock sync, app time set, SNTP): arms the + * 7-day suppression window AND drift-envelope pedigree. Suppression + * gates bootstrap too. */ void noteManualSync(uint32_t uptime_secs); - /* GPS time sync: arms pedigree only (stepping is gated off by the role - * whenever GPS is available and enabled, so no suppression needed). */ + /* GPS time sync: arms pedigree only (stepping is gated by GPS fix + * freshness, so no suppression needed). */ void noteGPSSync(uint32_t uptime_secs); - /* Report an applied step (rate-limit anchor + counters). local_time is - * the wall clock AFTER the step (display only). */ - void noteStepApplied(int64_t delta, uint32_t local_time, uint32_t uptime_secs, - bool bootstrap); - /* Forward-only roles report a refused backward verdict. */ - void noteBackwardSkipped() { _backward_skips++; } - bool isSuppressed(uint32_t uptime_secs) const; - uint32_t suppressRemaining(uint32_t uptime_secs) const; - - /* Table access for the CLI evidence dump. */ - static int tableSize() { return MESHTIMESYNC_TABLE_SIZE; } - const Slot &slotAt(int i) const { return _slots[i]; } - bool slotEligible(const Slot &s, uint32_t uptime_secs) const; - int64_t slotSkew(const Slot &s, uint32_t local_time, uint32_t uptime_secs) const; - - /* Counters for CLI/stats. */ - uint32_t evalCount() const { return _evals; } - uint32_t abstainCount() const { return _abstains; } - uint32_t stepCount() const { return _steps; } - uint32_t bootstrapStepCount() const { return _bootstrap_steps; } - uint32_t backwardSkipCount() const { return _backward_skips; } + /* Delta of the most recent applied step (for role bookkeeping shifts). */ int64_t lastStepDelta() const { return _last_step_delta; } - uint32_t lastStepWall() const { return _last_step_wall; } - bool hasStepped() const { return _stepped_once; } /* Compact status + evidence-table formatter shared by all role CLIs. * Writes at most `cap` bytes (NUL-terminated), summary first, then as - * many per-sender entries as fit. */ + * many per-sender entries as fit. Annotates verdicts the step policy + * would refuse ("gps-gated", "skipped: forward-only"). */ int formatStatus(char *out, size_t cap, uint32_t local_time, uint32_t uptime_secs, bool enabled) const; - static const char *reasonStr(Reason r); - private: + bool isBootstrap(uint32_t local_time) const { return local_time < _build_epoch; } + Slot *findSlot(const uint8_t *prefix); bool slotTenured(const Slot &s, uint32_t uptime_secs) const; + bool slotEligible(const Slot &s, uint32_t uptime_secs) const; + int64_t slotSkew(const Slot &s, uint32_t local_time, uint32_t uptime_secs) const; + + Consensus computeConsensus(uint32_t local_time, uint32_t uptime_secs, + bool bootstrap) const; + /* Unpaced full policy evaluation (no counter/pacing side effects). */ + Verdict evaluateNow(uint32_t local_time, uint32_t uptime_secs) const; + /* Paced evaluation — VERDICT_NONE/REASON_NONE between evaluations. */ + Verdict tick(uint32_t local_time, uint32_t uptime_secs); + + void noteStepApplied(int64_t delta, uint32_t uptime_secs, bool bootstrap); + bool isSuppressed(uint32_t uptime_secs) const; + uint32_t suppressRemaining(uint32_t uptime_secs) const; + static const char *reasonStr(Reason r); Slot _slots[MESHTIMESYNC_TABLE_SIZE]; uint32_t _build_epoch; + bool _forward_only; /* Policy state — uptime-anchored (see header comment). */ uint32_t _next_eval_uptime; @@ -186,8 +192,7 @@ private: uint32_t _last_step_uptime; bool _stepped_once; - /* Counters. */ + /* Counters (shown by formatStatus). */ uint32_t _evals, _abstains, _steps, _bootstrap_steps, _backward_skips; int64_t _last_step_delta; - uint32_t _last_step_wall; };