meshtimesync p2

This commit is contained in:
liquidraver committed 2026-07-03 19:30:53 +02:00
1 parent 14cb8c61f9
commit 46d27a0144
17 files changed
+262 -268

No files matched your search

+2 -2
View File
@@ -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.
-84
View File
@@ -1,84 +0,0 @@
# Mesh Time Sync
## What it does
Your node listens to the timestamps inside other nodes' signed advertisements
and, if its own clock is clearly wrong, gently corrects it — no GPS and no
phone needed. It works on every role (repeater, room server, observer,
companion). Why care: a node with a wrong clock shows garbage "last heard"
times, and after a reboot without a time sync its own adverts can be silently
ignored by every node that already knows it. On the live mesh today, almost
half of all repeaters are more than an hour off; this feature fixes that
class of problem automatically.
Default is **OFF**. Nothing changes unless you enable it.
## How to switch it on
```
set meshtimesync on
```
over the USB serial CLI or remote admin (repeaters, room servers, observers).
On companions, toggle the `meshtimesync` custom variable from the app, or use
the same command on the USB text CLI.
Check what it is doing with:
```
get meshtimesync
```
This shows a live view even while the feature is off — how many trustworthy
senders it can see, how many agree, what correction it *would* make. It is
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 overrides a recent manual `time <epoch>` 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.
- 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
reboot needs only 3 agreeing senders to recover).
- Room servers and companions never step backward (that would break message
ordering / get their messages dropped as replays); a backward verdict is
only reported in `get meshtimesync`.
## Drawbacks and honest limitations
- Small meshes (fewer than ~7 advert-active nodes in range) mostly won't
reach quorum, so normal correction stays inactive — dead-clock recovery
after a reboot still works (needs only 3).
- If most nodes around you share the SAME wrong time (it happens — one
region of the live mesh has a 60+ node island that is 28 hours fast), your
node may step toward them. The damage is bounded to 4 hours per day and
every step is visible in the logs and in `get meshtimesync`.
- A backward step mutes this node's own adverts at peers for a window equal
to the step size. It self-heals as their clocks pass the old mark, or
delete + re-add the contact in the app to recover instantly.
- Worst case — a coordinated wrong majority around you: turn it off
(`set meshtimesync off`) and set the clock manually, which also arms the
7-day protection.
## Recovery recipe
If the clock ever ends up AHEAD of true time (`time` alone will refuse with
"clock cannot go backwards"):
```
set meshtimesync off
clkreboot
(log back in)
time <correct epoch>
set meshtimesync on
```
Works over remote admin too. The off/on bracket makes it deterministic (no
bootstrap race), and the manual set arms the 7-day protection anyway. Fresh
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.
+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)`), 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 <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 GPS 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 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 <hex>` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* |
---
+3 -37
View File
@@ -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)
+4 -7
View File
@@ -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;
+13 -29
View File
@@ -9,8 +9,6 @@
#include <mesh/Utils.h>
#include <mesh/LoRaConfig.h>
#include <adapters/radio/LoRaRadioBase.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <adapters/gps/ZephyrGPSManager.h>
#include <helpers/MeshcoreJson.h>
#include <zephyr/logging/log.h>
@@ -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
-2
View File
@@ -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 */
+1 -1
View File
@@ -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
+23 -43
View File
@@ -12,8 +12,6 @@
#include <adapters/sensors/SimpleLPP.h>
#include <adapters/sensors/ZephyrEnvSensors.h>
#include <adapters/gps/ZephyrGPSManager.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <helpers/time_sync.h>
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
@@ -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 {
-1
View File
@@ -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);
+13 -41
View File
@@ -19,8 +19,6 @@
#include <adapters/sensors/SimpleLPP.h>
#include <adapters/sensors/ZephyrEnvSensors.h>
#include <adapters/gps/ZephyrGPSManager.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <helpers/time_sync.h>
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
@@ -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 {
+4 -4
View File
@@ -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)); }
+6 -1
View File
@@ -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())) {
+5 -4
View File
@@ -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);
+6
View File
@@ -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
+113 -35
View File
@@ -6,13 +6,42 @@
#include "MeshTimeSync.h"
#include <adapters/gps/ZephyrGPSManager.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <helpers/time_sync.h>
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <stdio.h>
#include <string.h>
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) {
+62 -57
View File
@@ -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 <stdint.h>
#include <stddef.h>
#include <mesh/RTC.h>
#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;
};