mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-01 20:09:17 +00:00
meshtimesync p2
This commit is contained in:
@@ -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 <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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)* |
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user