From c36aec98b026b22ca824d9bd29faca49a9e5a8fb Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:05:31 +0200 Subject: [PATCH] unify hold gate for timesync --- MESHTIMESYNC.md | 12 +++++------ zephcore/ARCHITECTURE.md | 5 ++--- zephcore/Repeater_CLI_commands.md | 4 ++-- zephcore/helpers/MeshTimeSync.cpp | 34 ++++++++++--------------------- zephcore/helpers/MeshTimeSync.h | 24 +++++++++------------- 5 files changed, 31 insertions(+), 48 deletions(-) diff --git a/MESHTIMESYNC.md b/MESHTIMESYNC.md index e71b615..137dc75 100644 --- a/MESHTIMESYNC.md +++ b/MESHTIMESYNC.md @@ -35,12 +35,12 @@ worth watching that dry-run output for a day before (and after) enabling. ## What it will NOT do -- 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 fights a recent clock set, whether from GPS or from you: any GPS + fix or manual `time ` / `clock sync` protects the clock from + automatic changes for 7 days (the same mechanism for both). A live GPS + re-arms this on every fix, so it keeps owning the clock. If GPS goes dead + (indoors, dead antenna), mesh correction takes over once 7 days pass + without a fix — until the next real fix. - It never steps more than 1 hour at a time, and at most one step per 6 hours. - It does nothing unless at least 6 trustworthy senders are visible and a strict majority of them agree (exception: a provably dead clock after a diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 7ae2658..4c5b294 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -331,9 +331,8 @@ 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: 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. +**Correction policy** (priority: GPS / manual set > mesh consensus): +- Any clock set — GPS fix **or** manual set (`time`, `clock sync`, app time set) — arms the same **7-day suppression** of all stepping, bootstrap included, plus drift-envelope pedigree (`noteGPSSync` and `noteManualSync` are identical). A live GPS re-arms it on every fix (so a repeater's 48 h duty cycle keeps GPS owning the clock); a GPS that cannot fix (indoors, dead antenna) becomes mesh-correctable once 7 days pass without a fix. Sensing always continues; a suppressed node shows `hold (suppressed)` in the dry-run. - 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. - **Bootstrap**: local time < firmware build epoch (`FIRMWARE_BUILD_EPOCH`, CMake-injected) is provably wrong → any 3 agreeing senders, step to the cluster's **low edge** (midpoint − 150 s; undershoot so later refinement is always forward = monotonicity-safe). diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index f555969..fe17251 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)`; 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 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)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(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 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 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 clock set in the last 7 days, whether from GPS (re-armed on every fix) or a manual set. 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/helpers/MeshTimeSync.cpp b/zephcore/helpers/MeshTimeSync.cpp index 15dced8..a835d1e 100644 --- a/zephcore/helpers/MeshTimeSync.cpp +++ b/zephcore/helpers/MeshTimeSync.cpp @@ -6,7 +6,6 @@ #include "MeshTimeSync.h" -#include #include #include #include @@ -24,19 +23,6 @@ static long clampl(int64_t v) 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)); @@ -347,11 +333,6 @@ bool MeshTimeSync::runTick(mesh::RTCClock &rtc) } 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 @@ -390,6 +371,13 @@ void MeshTimeSync::noteManualSync(uint32_t uptime_secs) void MeshTimeSync::noteGPSSync(uint32_t uptime_secs) { + /* A GPS fix owns the clock exactly like a manual set: arm the same 7-day + * suppression window (re-armed on every fix, so a repeater's 48 h GPS + * duty cycle keeps it owning the clock) plus the drift-envelope pedigree. + * A unit whose GPS goes dead becomes mesh-correctable once the window + * lapses without a new fix. */ + _suppress_uptime = uptime_secs; + _suppressed = true; _pedigree_uptime = uptime_secs; _pedigree = true; } @@ -437,11 +425,11 @@ int MeshTimeSync::formatStatus(char *out, size_t cap, uint32_t local_time, char verdict[56]; if (v.type == VERDICT_STEP) { /* Annotate steps the shared step policy would refuse, so the - * dry-run never claims a step that will not happen. */ + * dry-run never claims a step that will not happen. (Suppression — + * manual or GPS — surfaces as a hold verdict inside evaluateNow, + * so it needs no annotation here.) */ const char *note = ""; - if (gps_gate_active()) { - note = " (gps-gated)"; - } else if (_forward_only && v.delta < 0) { + if (_forward_only && v.delta < 0) { note = " (skipped: forward-only)"; } snprintf(verdict, sizeof(verdict), "step%+ld%s%s", clampl(v.delta), diff --git a/zephcore/helpers/MeshTimeSync.h b/zephcore/helpers/MeshTimeSync.h index 6779e78..d689d17 100644 --- a/zephcore/helpers/MeshTimeSync.h +++ b/zephcore/helpers/MeshTimeSync.h @@ -4,9 +4,9 @@ * * 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 + * loop; runTick applies the shared policy (7-day suppression armed by any + * manual or GPS clock set, 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. @@ -60,12 +60,6 @@ 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 @@ -92,7 +86,7 @@ public: REASON_NO_DATA, /* no eligible voters */ REASON_NO_QUORUM, REASON_NO_MAJORITY, - REASON_SUPPRESSED, /* manual clock set less than 7 days ago */ + REASON_SUPPRESSED, /* manual or GPS clock set less than 7 days ago */ REASON_RATE_LIMITED, /* < 6 h since last applied step */ REASON_PEDIGREE, /* drift-envelope physics veto */ }; @@ -145,8 +139,9 @@ public: * 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 by GPS fix - * freshness, so no suppression needed). */ + /* GPS time sync: identical to a manual set — arms the same 7-day + * suppression window and drift-envelope pedigree, re-armed on every fix + * so a live GPS keeps owning the clock. */ void noteGPSSync(uint32_t uptime_secs); /* Delta of the most recent applied step (for role bookkeeping shifts). */ @@ -154,8 +149,9 @@ public: /* 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. Annotates verdicts the step policy - * would refuse ("gps-gated", "skipped: forward-only"). */ + * many per-sender entries as fit. Annotates a backward step a + * forward-only role would refuse ("skipped: forward-only"); a suppressed + * clock (manual or GPS) surfaces as a "hold (suppressed)" verdict. */ int formatStatus(char *out, size_t cap, uint32_t local_time, uint32_t uptime_secs, bool enabled) const;