unify hold gate for timesync

This commit is contained in:
liquidraver
2026-07-04 12:05:31 +02:00
parent fe14c73f52
commit c36aec98b0
5 changed files with 31 additions and 48 deletions
+6 -6
View File
@@ -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 <epoch>` 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 <epoch>` / `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
+2 -3
View File
@@ -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).
+2 -2
View File
@@ -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 <mult>` | (0 = use board default) | Battery voltage ADC calibration multiplier |
| `set meshtimesync <on\|off>` | 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 <on\|off>` | 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 <hex>` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* |
---
+11 -23
View File
@@ -6,7 +6,6 @@
#include "MeshTimeSync.h"
#include <adapters/gps/ZephyrGPSManager.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <helpers/time_sync.h>
#include <zephyr/kernel.h>
@@ -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),
+10 -14
View File
@@ -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;