diff --git a/.gitignore b/.gitignore index 3429efd..631c483 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,5 @@ MISC_AUDIT_INDEX.md HANDOVER_issue34_ble_esp32.md RELEASE_NOTES_v1.16.3.md PATH_HASH_AUDIT_INDEX.md +MESHTIMESYNC_PLAN.md +/meshtimesync-sim/ diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 0c21438..5cc329d 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -287,6 +287,44 @@ Direct (source-routed) packets bypass adaptive scaling entirely. They use minima - **Advertisements**: Ed25519 signature over (pubkey + timestamp + app_data) - **ACKs**: SHA-256(shared_secret + packet_hash) truncated to 4 bytes +### 4.9 Mesh Time Sync (Clock Consensus) + +ZephCore-only divergence from Arduino MeshCore (like the Adaptive Contention Window). A node senses its own clock error from the Ed25519-signed timestamps in other nodes' adverts and — **opt-in, default off** (`set meshtimesync on`) — corrects it automatically. There is no trusted reference clock on a mesh, so this is a *consensus estimation* problem: the node assumes the majority of tenured advert senders within 3 flood hops is right. User-facing doc: `MESHTIMESYNC.md` at the repo root. + +**Module**: `helpers/MeshTimeSync.{h,cpp}` — role-agnostic estimator, owns no clock. Each role feeds it verified adverts (`onAdvertHeard`), calls `tick()` periodically (15-min pacing internal), and applies STEP verdicts under its own policy. + +**Sample table** (per-sender, `CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE` slots: 32 default, 16 on RAM-bound companions; 24 B/slot): +- 8-byte pubkey prefix — a security floor, not a tuning knob (shorter prefixes are grindable: an attacker could collide a tenured voter's prefix and reset its tenure with validly-signed adverts). +- Latest advert timestamp (= the vote, per-sender monotonic — replays and flood dupes are inert) + arrival **uptime**. Skew is recomputed at evaluate time from the uptime anchor, so the node's own steps never stale stored samples. +- Tenure tracking: first-heard uptime, advert count. Eligibility = heard ≥ 1 h, ≥ 2 adverts, latest sample ≤ 5 days old (bridges the 47 h flood-advert cadence). +- Self-consistency: consecutive samples must satisfy `|Δadvert_ts − Δuptime| ≤ 45 s + 150 ppm × Δuptime`; violation (sender rebooted/corrected/lying) resets that sender's tenure. +- **Hop-priority admission** (hop cap 3): a new sender may only displace a young entry farther (higher hop) than it; mature entries are protected unless silent > 24 h. Naive LRU churned hub nodes to zero eligible voters in simulation. + +**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). +- 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. +- **Bootstrap**: local time < firmware build epoch (`FIRMWARE_BUILD_EPOCH`, CMake-injected) is provably wrong → any 3 agreeing senders, step to the cluster's **low edge** (midpoint − 150 s; undershoot so later refinement is always forward = monotonicity-safe). + +**Per-role step policy** (policy lives in the role, not the estimator): +| Role | Policy | Why | +|---|---|---| +| Repeater | bidirectional | clock not load-bearing: forwarding/dedup/remote-admin run on `millis()`/hashes; a backward step only mutes own adverts at peers for a window equal to the step | +| Observer | bidirectional | clock only stamps observations — exactly what this fixes | +| 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. + +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. + +**CLI**: `set meshtimesync {on|off}`, `get meshtimesync` → state + live dry-run (eligible count, votes for/against, skew/radius, would-be verdict) + per-sender evidence table (full table over local USB; remote admin replies are summary-truncated to fit the packet). Sensing always runs, so the dry-run works before enabling. + +**Accepted limits**: a coordinated same-offset majority around a node captures it (no consensus survives that — Bitcoin timejacking lesson; mitigations: default-off, manual override, caps); sub-quorum islands abstain forever (bootstrap still heals dead clocks with 3 senders). + --- ## 5. Radio Subsystem diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index e140042..258d2cf 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -427,6 +427,12 @@ project(zephcore) string(TIMESTAMP ZEPHCORE_BUILD_DATE "%Y %b %d" UTC) add_definitions(-DFIRMWARE_BUILD_DATE="${ZEPHCORE_BUILD_DATE}") +# Build-time UNIX epoch: mesh time sync's "provably dead clock" floor (a local +# time below this is impossible for a running build). string(TIMESTAMP) honors +# SOURCE_DATE_EPOCH for reproducible builds. +string(TIMESTAMP ZEPHCORE_BUILD_EPOCH "%s" UTC) +add_definitions(-DFIRMWARE_BUILD_EPOCH=${ZEPHCORE_BUILD_EPOCH}u) + # Single source of truth for the firmware version string (vMAJOR.MINOR.PATCH-zephyr). # Injected globally like FIRMWARE_BUILD_DATE above, so every app TU sees the same # value; the per-app `#ifndef FIRMWARE_VERSION` fallbacks only apply to builds that @@ -473,6 +479,7 @@ target_sources(app PRIVATE adapters/gps/ZephyrGPSManager.cpp adapters/sensors/ZephyrEnvSensors.cpp helpers/AdvertDataHelpers.cpp + helpers/MeshTimeSync.cpp helpers/oled_power.c helpers/fatal_reboot.c ) diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 0e1c181..d5d774b 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -201,6 +201,31 @@ config ZEPHCORE_MAX_UNSYNCED_POSTS endif # ZEPHCORE_ROLE_REPEATER || ZEPHCORE_ROLE_ROOM_SERVER +config ZEPHCORE_TIMESYNC_QUORUM + int "Mesh time sync: consensus quorum" + default 6 + range 3 32 + help + Minimum eligible advert senders required before a mesh time-sync + consensus may step the clock (a strict majority of them must also + agree). Each quorum unit costs a local attacker one more physical + radio sustained for tenure-hours, so lowering this trades eclipse + resistance for coverage on small meshes (e.g. 7 repeaters = exactly + 6 potential voters). Floor 3 matches the bootstrap quorum — below + that the interval intersection degenerates. Deliberately a build-time + knob, not a runtime pref: this is a security parameter. + +config ZEPHCORE_TIMESYNC_TABLE_SIZE + int "Mesh time sync: sample table slots" + default 16 if ZEPHCORE_ROLE_COMPANION + default 32 + range 8 64 + help + Per-sender advert sample slots (24 bytes each) for the mesh + time-sync consensus. 32 fits dense neighborhoods; companions + default to 16 (RAM-bound role). Must be at least the quorum for + normal-mode stepping to be reachable. + endmenu # Device Role if ZEPHCORE_ROLE_COMPANION diff --git a/zephcore/MESHTIMESYNC.md b/zephcore/MESHTIMESYNC.md new file mode 100644 index 0000000..8b34b9d --- /dev/null +++ b/zephcore/MESHTIMESYNC.md @@ -0,0 +1,84 @@ +# 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 ` 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 +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. diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index dc8d6c0..0671b88 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -30,8 +30,8 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the | Command | Description | |---------|-------------| | `clock` | Display current UTC time | -| `clock sync` | Sync clock from the sender's timestamp (only advances, cannot go backwards) | -| `time ` | Set RTC to a specific Unix timestamp (cannot go backwards) | +| `clock sync` | Sync clock from the sender's timestamp (only advances, cannot go backwards). Arms the 7-day mesh-time-sync suppression window. | +| `time ` | Set RTC to a specific Unix timestamp (cannot go backwards). Arms the 7-day mesh-time-sync suppression window. | --- @@ -206,6 +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 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 | @@ -250,6 +251,7 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost, applied live. Replies `Error: unsupported` on radios without RX boost (SX127x); the pref is still saved. | | `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | | `set adc.multiplier ` | (0 = use board default) | Battery voltage ADC calibration multiplier | +| `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides GPS or a manual set less than 7 days old. See `MESHTIMESYNC.md`. | | `set prv.key ` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* | --- diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 1b5499b..c04bbb3 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -663,6 +663,14 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) prefs.rx_duty_cycle = 0; } } + + /* Offset 151: meshtimesync (ZephCore extension, default 0 = off) */ + if (off < len) { + prefs.meshtimesync = buf[off++]; + if (prefs.meshtimesync > 1) { + prefs.meshtimesync = 0; + } + } } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) @@ -737,7 +745,9 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) buf[off++] = (prefs.auto_shutdown_mv >> 8) & 0xFF; /* Offset 150: rx_duty_cycle (ZephCore extension) */ buf[off++] = prefs.rx_duty_cycle; - /* Total: 151 bytes */ + /* Offset 151: meshtimesync (ZephCore extension) */ + buf[off++] = prefs.meshtimesync; + /* Total: 152 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index c0a9b38..eef252b 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -342,6 +342,56 @@ void CompanionMesh::loop() } } +void CompanionMesh::onAdvertTimeSample(const mesh::Identity &id, uint32_t timestamp, + uint8_t hops) +{ + /* Signature already verified by mesh::Mesh before this hook fires. */ + _timesync.onAdvertHeard(id.pub_key, timestamp, hops, + (uint32_t)(k_uptime_get() / 1000)); +} + +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); +} + void CompanionMesh::onLoginSent(const ContactInfo &contact) { memcpy(&_pending_login, contact.id.pub_key, 4); @@ -2237,6 +2287,7 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) getRTCClock()->setCurrentTime(secs); time_sync_report(TIME_SYNC_APP); zephcore_rtc_save(secs); /* persist to hardware RTC */ + _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); sendPacketOk(); } else { sendPacketError(ERR_ILLEGAL_ARG); @@ -2750,10 +2801,23 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) int n = snprintf(dp, remaining, "gps_interval:%u", (unsigned)gps_interval); if (n > 0 && (size_t)n < remaining) { dp += n; + first = false; } // If snprintf would have truncated, dp stays put — writeFrame // sends only what we successfully wrote. } + { + size_t remaining = (size_t)(rsp_end - dp); + if (!first && remaining > 0) { + *dp++ = ','; + remaining--; + } + int n = snprintf(dp, remaining, "meshtimesync:%d", + prefs.meshtimesync ? 1 : 0); + if (n > 0 && (size_t)n < remaining) { + dp += n; + } + } // Note: Environment sensors are auto-detected, no settings needed writeFrame(rsp, (size_t)(dp - (char *)rsp)); @@ -2792,6 +2856,10 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) } else { sendPacketError(ERR_ILLEGAL_ARG); } + } else if (strcmp(key, "meshtimesync") == 0) { + prefs.meshtimesync = (val[0] == '1') ? 1 : 0; + _store->savePrefs(prefs); + sendPacketOk(); } else { sendPacketError(ERR_ILLEGAL_ARG); } diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 24d9b95..ae58c54 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -6,9 +6,11 @@ #pragma once #include +#include #include #include #include +#include /* BLE push notification codes */ #define PUSH_CODE_ADVERT 0x80 @@ -231,6 +233,15 @@ 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) */ + MeshTimeSync *getMeshTimeSync() { return &_timesync; } + void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + /* Paced evaluation — called from the housekeeping event (loop() only runs + * on packet-driven events). */ + void timeSyncTick(); + /* Prefs (includes node_lat/lon) */ NodePrefs prefs; @@ -369,6 +380,13 @@ private: void flushDirtyContacts(); void flushDirtyChannels(); + /* Mesh time sync */ + MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; + void onAdvertTimeSample(const mesh::Identity &id, uint32_t timestamp, + uint8_t hops) override; + void applyTimeSyncStep(const MeshTimeSync::Verdict &v, uint32_t now, + uint32_t uptime_secs); + /* Protocol version negotiation */ uint8_t _app_target_ver; diff --git a/zephcore/app/ObserverMesh.cpp b/zephcore/app/ObserverMesh.cpp index 478621e..d809a0b 100644 --- a/zephcore/app/ObserverMesh.cpp +++ b/zephcore/app/ObserverMesh.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include @@ -217,9 +219,80 @@ DispatcherAction ObserverMesh::onRecvPacket(Packet *pkt) * The same flood packet heard from different repeaters is published * separately, each with its own SNR/RSSI (propagation data). */ enqueuePacket(pkt); + harvestTimeSample(pkt); return ACTION_RELEASE; /* never retransmit */ } +/* ========== Mesh time sync ========== */ + +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; + + int i = 0; + Identity id; + memcpy(id.pub_key, &pkt->payload[i], PUB_KEY_SIZE); + i += PUB_KEY_SIZE; + uint32_t timestamp; + memcpy(×tamp, &pkt->payload[i], 4); + i += 4; + const uint8_t *signature = &pkt->payload[i]; + i += SIGNATURE_SIZE; + + 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; + + uint8_t message[PUB_KEY_SIZE + 4 + MAX_ADVERT_DATA_SIZE]; + int msg_len = 0; + memcpy(&message[msg_len], id.pub_key, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE; + memcpy(&message[msg_len], ×tamp, 4); msg_len += 4; + memcpy(&message[msg_len], &pkt->payload[i], app_data_len); msg_len += app_data_len; + + if (!id.verify(signature, message, msg_len)) return; + + _timesync.onAdvertHeard(id.pub_key, timestamp, pkt->getPathHashCount(), + (uint32_t)(k_uptime_get() / 1000)); +} + +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); +} + +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 @@ -289,6 +362,12 @@ bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size) snprintf(reply, reply_size, "%u", (_creds) ? (unsigned)_creds->mqtt_port : 8883u); + } else if (strcmp(key, "meshtimesync") == 0) { + _timesync.formatStatus(reply, reply_size, + _rtc ? _rtc->getCurrentTime() : 0, + (uint32_t)(k_uptime_get() / 1000), + _prefs.meshtimesync != 0); + } else if (strcmp(key, "mqtt.tls") == 0) { snprintf(reply, reply_size, "%u", (_creds) ? (unsigned)_creds->mqtt_tls : 1u); @@ -392,6 +471,19 @@ bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size) snprintf(reply, reply_size, "ERR cr must be 5-8"); } + } else if ((val = find_val(rest, "meshtimesync")) != nullptr) { + if (strcmp(val, "on") == 0) { + _prefs.meshtimesync = 1; + _store->savePrefs(_prefs); + snprintf(reply, reply_size, "meshtimesync=on"); + } else if (strcmp(val, "off") == 0) { + _prefs.meshtimesync = 0; + _store->savePrefs(_prefs); + snprintf(reply, reply_size, "meshtimesync=off"); + } else { + snprintf(reply, reply_size, "ERR must be on or off"); + } + } else if (!_creds) { snprintf(reply, reply_size, "ERR creds not initialized"); diff --git a/zephcore/app/ObserverMesh.h b/zephcore/app/ObserverMesh.h index eba463d..c311bee 100644 --- a/zephcore/app/ObserverMesh.h +++ b/zephcore/app/ObserverMesh.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "RepeaterDataStore.h" #include "observer_creds.h" @@ -58,6 +59,13 @@ class ObserverMesh : public Dispatcher { void buildStatusJson(const char *status, char *out, size_t out_size); uint32_t _start_uptime_secs; + /* Mesh time sync — the observer bypasses mesh::Mesh, so it verifies + * 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 */ void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; @@ -84,6 +92,13 @@ public: void publishSelfAdvert(); void publishStatus(const char *status); + /* Mesh time sync: paced evaluation (bidirectional stepping — the clock + * is only load-bearing for observation timestamps, which is exactly + * what this fixes). Driven from the 300 s status timer. */ + void timeSyncTick(); + /* SNTP just set the clock (trusted): arm suppression + drift envelope. */ + void noteTrustedTimeSync(); + /* Accessors used by main_observer.cpp */ NodePrefs *getNodePrefs() { return &_prefs; } const LocalIdentity &getSelfId() const { return _self_id; } diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 692b1a8..3293df5 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -201,6 +201,8 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { * read leaves the constructor defaults flood_max_unscoped=64, flood_max_advert=8). */ fs_read(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); fs_read(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); + /* Mesh time sync (absent in <297-byte files; no-op EOF read keeps default 0 = off) */ + fs_read(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync)); fs_close(&file); @@ -231,6 +233,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { if (prefs.rx_duty_cycle > 1) prefs.rx_duty_cycle = 0; if (prefs.apc_enabled > 1) prefs.apc_enabled = 0; if (prefs.apc_margin < 6 || prefs.apc_margin > 30) prefs.apc_margin = 16; + if (prefs.meshtimesync > 1) prefs.meshtimesync = 0; /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore * extension fields, and stored path_hash_mode/loop_detect as zero padding. @@ -328,6 +331,8 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { /* Flood hop-ceiling extensions (extend the format past 294 bytes) */ fs_write(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); fs_write(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); + /* Mesh time sync on/off (offset 296) */ + fs_write(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync)); ret = fs_sync(&file); fs_close(&file); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 374bad9..db5da08 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -711,6 +713,10 @@ 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)); + if (packet->getPathHashCount() == 0 && !isShare(packet)) { AdvertDataParser parser(app_data, app_data_len); if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { @@ -1374,11 +1380,71 @@ void RepeaterMesh::loop() { } #endif + timeSyncTick(); + uint32_t now = k_uptime_get(); uptime_millis += now - last_millis; last_millis = now; } +void RepeaterMesh::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) { + 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. */ +#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; + 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; + c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; + } + /* A backward step would otherwise wedge these shut until wall-clock + * catch-up. */ + 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 { return _mgr->getOutboundTotal() > 0; } diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index 5628558..4f54106 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -112,6 +113,7 @@ class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t pending_sf; uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; + MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) ObserverCreds _uplink_creds; bool _uplink_reboot_required; @@ -131,6 +133,8 @@ class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks { #endif 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); @@ -244,6 +248,10 @@ public: void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; + /* Mesh time sync */ + MeshTimeSync* getMeshTimeSync() override { return &_timesync; } + void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + /* Adaptive contention window callbacks */ float getContentionEstimate() const override { return getContentionTracker().getContentionEstimate(); diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 9e97751..75f63a8 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include #include #include @@ -1104,11 +1106,70 @@ void RoomServerMesh::loop() { dirty_contacts_expiry = 0; } + timeSyncTick(); + uint32_t now = k_uptime_get(); uptime_millis += now - last_millis; last_millis = now; } +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. */ + _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), + (uint32_t)(k_uptime_get() / 1000)); +} + +void RoomServerMesh::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: 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). */ + 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; + 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 { return _mgr->getOutboundTotal() > 0; } diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index b3d67fd..d7354a0 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -94,9 +95,12 @@ 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}; 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); @@ -145,6 +149,7 @@ protected: bool filterRecvFloodPacket(mesh::Packet* pkt) override; + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; @@ -192,6 +197,11 @@ 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) */ + MeshTimeSync* getMeshTimeSync() override { return &_timesync; } + void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + /* Adaptive contention window callbacks */ float getContentionEstimate() const override { return getContentionTracker().getContentionEstimate(); diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index c51d096..6c7a144 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -173,11 +173,13 @@ static void print_banner(void) cli_println("set sf <7-12> Spreading factor"); cli_println("set bw Bandwidth: 3=62.5 0=125 1=250 2=500 kHz"); cli_println("set cr <5-8> Coding rate"); + cli_println("set meshtimesync Mesh clock consensus correction"); cli_println(""); cli_println("--- Query ---"); cli_println("get wifi.status WiFi connection state"); cli_println("get mqtt.status MQTT connection state"); cli_println("get radio LoRa radio parameters"); + cli_println("get meshtimesync Time-sync consensus state (dry-run)"); cli_println("help Show this screen"); cli_println("========================="); } @@ -228,9 +230,14 @@ static void process_cli_rx(void) static mesh::ZephyrRTCClock s_rtc_clock; +/* SNTP callback runs on the WiFi thread; the mesh time-sync module is + * main-thread-only, so just flag the sync and let the event loop arm it. */ +static atomic_t s_sntp_synced; + static void time_sync_cb(uint32_t unix_ts) { s_rtc_clock.setCurrentTime(unix_ts); + atomic_set(&s_sntp_synced, 1); LOG_INF("RTC synced from SNTP: %u", unix_ts); } @@ -366,8 +373,13 @@ int main(void) if (ev & MESH_EVENT_CLI_RX) { process_cli_rx(); } + if (atomic_cas(&s_sntp_synced, 1, 0)) { + /* SNTP set the clock — arm suppression + drift envelope. */ + observer_mesh.noteTrustedTimeSync(); + } if (ev & MESH_EVENT_STATUS) { observer_mesh.publishStatus("online"); + observer_mesh.timeSyncTick(); } if (ev & MESH_EVENT_MQTT_CONNECT) { observer_mesh.publishStatus("online"); diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index e8f283c..f0e4042 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -139,6 +139,8 @@ 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()); + AdvertDataParser parser(app_data, app_data_len); if (!(parser.isValid() && parser.hasName())) { LOG_WRN("onAdvertRecv: invalid parser or no name (valid=%d, hasName=%d)", diff --git a/zephcore/helpers/BaseChatMesh.h b/zephcore/helpers/BaseChatMesh.h index 610472b..33b439c 100644 --- a/zephcore/helpers/BaseChatMesh.h +++ b/zephcore/helpers/BaseChatMesh.h @@ -157,6 +157,11 @@ protected: virtual void onLoginSent(const ContactInfo &contact) {} virtual void onChannelAdded(ChannelDetails *ch) {} + /* Every signature-verified advert, before contact filtering/dedup — + * mesh time-sync harvesting hook. */ + virtual void onAdvertTimeSample(const mesh::Identity &id, uint32_t timestamp, + uint8_t hops) { (void)id; (void)timestamp; (void)hops; } + // Storage concepts for subclasses to override virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 1372d4e..41f757b 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -5,6 +5,7 @@ #include "CommonCLI.h" #include "battery_curve.h" +#include #include #include #include @@ -119,6 +120,7 @@ void CommonCLI::loadPrefs(const char* path) { ok = ok && prefs_read(&file, &_prefs->apc_margin, sizeof(_prefs->apc_margin)); // 293 ok = ok && prefs_read(&file, &_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 294 ok = ok && prefs_read(&file, &_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 295 + ok = ok && prefs_read(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); // 296 if (!ok) { LOG_WRN("Prefs file %s truncated, some fields use defaults", path); @@ -164,6 +166,7 @@ void CommonCLI::loadPrefs(const char* path) { _prefs->apc_margin = constrain(_prefs->apc_margin, (uint8_t)6, (uint8_t)30); _prefs->flood_max_unscoped = constrain(_prefs->flood_max_unscoped, (uint8_t)0, (uint8_t)64); _prefs->flood_max_advert = constrain(_prefs->flood_max_advert, (uint8_t)0, (uint8_t)64); + _prefs->meshtimesync = constrain(_prefs->meshtimesync, (uint8_t)0, (uint8_t)1); LOG_INF("Loaded prefs from %s", path); } @@ -231,6 +234,7 @@ void CommonCLI::savePrefs(const char* path) { fs_write(&file, &_prefs->apc_margin, sizeof(_prefs->apc_margin)); fs_write(&file, &_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); fs_write(&file, &_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); + fs_write(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); fs_close(&file); LOG_INF("Saved prefs to %s", path); @@ -339,6 +343,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch getRTCClock()->setCurrentTime(sender_timestamp + 1); time_sync_report(TIME_SYNC_CLI); zephcore_rtc_save(sender_timestamp + 1); /* persist to hardware RTC */ + MeshTimeSync* ts = _callbacks->getMeshTimeSync(); + if (ts) ts->noteManualSync((uint32_t)(k_uptime_get() / 1000)); uint32_t now = getRTCClock()->getCurrentTime(); time_t t = (time_t)now; struct tm *tm = gmtime(&t); @@ -360,6 +366,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch getRTCClock()->setCurrentTime(secs); time_sync_report(TIME_SYNC_CLI); zephcore_rtc_save(secs); /* persist to hardware RTC */ + MeshTimeSync* ts = _callbacks->getMeshTimeSync(); + if (ts) ts->noteManualSync((uint32_t)(k_uptime_get() / 1000)); time_t t = (time_t)secs; struct tm *tm = gmtime(&t); snprintf(reply, CLI_REPLY_SIZE, "OK - clock set: %02d:%02d - %d/%d/%d UTC", @@ -538,6 +546,19 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else if (memcmp(config, "dc.restarts", 11) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_callbacks->getDutyCycleTimeoutRestarts()); + } else if (memcmp(config, "meshtimesync", 12) == 0) { + MeshTimeSync* ts = _callbacks->getMeshTimeSync(); + 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; + ts->formatStatus(reply, cap, getRTCClock()->getCurrentTime(), + (uint32_t)(k_uptime_get() / 1000), + _prefs->meshtimesync != 0); + } } else { snprintf(reply, CLI_REPLY_SIZE, "??: %s", config); } @@ -948,6 +969,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch if (val == 0) strcpy(reply, "OK - gps duty=0 (always on)"); else snprintf(reply, CLI_REPLY_SIZE, "OK - gps duty=%u s", (unsigned)val); } + } else if (memcmp(config, "meshtimesync ", 13) == 0) { + const char* arg = &config[13]; + if (_callbacks->getMeshTimeSync() == nullptr) { + strcpy(reply, "not available"); + } else if (memcmp(arg, "on", 2) == 0) { + _prefs->meshtimesync = 1; + savePrefs(); + strcpy(reply, "OK - meshtimesync on"); + } else if (memcmp(arg, "off", 3) == 0) { + _prefs->meshtimesync = 0; + savePrefs(); + strcpy(reply, "OK - meshtimesync off"); + } else { + strcpy(reply, "Error: must be on or off"); + } } else { snprintf(reply, CLI_REPLY_SIZE, "unknown config: %s", config); } diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 6892064..99ad211 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -12,6 +12,8 @@ #include #include "NodePrefs.h" +class MeshTimeSync; + /* CLI reply buffer size — callers must provide at least this many bytes */ #define CLI_REPLY_SIZE 256 @@ -74,6 +76,9 @@ public: virtual uint8_t getAPCTargetMargin() const { return 16; } virtual void setAPCTargetMargin(uint8_t margin_db) { (void)margin_db; } + // Mesh time sync (all roles wire one; nullptr = not compiled/available) + virtual MeshTimeSync* getMeshTimeSync() { return nullptr; } + // Sensor manager interface (for GPS) virtual double getNodeLat() const { return 0.0; } virtual double getNodeLon() const { return 0.0; } diff --git a/zephcore/helpers/MeshTimeSync.cpp b/zephcore/helpers/MeshTimeSync.cpp new file mode 100644 index 0000000..e2c741e --- /dev/null +++ b/zephcore/helpers/MeshTimeSync.cpp @@ -0,0 +1,432 @@ +/* + * SPDX-License-Identifier: MIT + * MeshTimeSync - mesh clock consensus from Ed25519-signed advert timestamps. + * See MeshTimeSync.h for the role contract, ARCHITECTURE.md for the design. + */ + +#include "MeshTimeSync.h" + +#include +#include + +void MeshTimeSync::reset(uint32_t build_epoch) +{ + memset(_slots, 0, sizeof(_slots)); + _build_epoch = build_epoch; + _next_eval_uptime = 0; + _suppress_uptime = 0; + _suppressed = false; + _pedigree_uptime = 0; + _pedigree = false; + _last_step_uptime = 0; + _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) +{ + for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) { + if (_slots[i].used && memcmp(_slots[i].prefix, prefix, sizeof(_slots[i].prefix)) == 0) { + return &_slots[i]; + } + } + return nullptr; +} + +bool MeshTimeSync::slotTenured(const Slot &s, uint32_t uptime_secs) const +{ + return (uptime_secs - s.first_uptime) >= TENURE_SECS && + s.count >= TENURE_MIN_ADVERTS; +} + +bool MeshTimeSync::slotEligible(const Slot &s, uint32_t uptime_secs) const +{ + return s.used && slotTenured(s, uptime_secs) && + (uptime_secs - s.arrival_uptime) <= MAX_SAMPLE_AGE_SECS; +} + +int64_t MeshTimeSync::slotSkew(const Slot &s, uint32_t local_time, uint32_t uptime_secs) const +{ + /* Project the sender's clock forward by our elapsed uptime, then compare + * with our wall clock. Uptime anchor keeps this valid across own steps. */ + return (int64_t)s.advert_ts + (int64_t)(uptime_secs - s.arrival_uptime) - + (int64_t)local_time; +} + +void MeshTimeSync::onAdvertHeard(const uint8_t *pubkey, uint32_t advert_ts, + uint8_t hops, uint32_t uptime_secs) +{ + if (hops > HOP_CAP) return; + + Slot *s = findSlot(pubkey); + if (s) { + if (advert_ts <= s->advert_ts) return; /* per-sender monotonic dedup */ + + uint32_t d_up = uptime_secs - s->arrival_uptime; + uint32_t d_ts = advert_ts - s->advert_ts; + int64_t err = (int64_t)d_ts - (int64_t)d_up; + int64_t tol = CONSISTENCY_BASE_SECS + ((int64_t)d_up * CONSISTENCY_PPM) / 1000000; + if (err > tol || err < -tol) { + /* Sender rebooted, corrected itself, or is lying — re-earn + * tenure from scratch. >150 ppm is beyond crystal physics. */ + s->first_uptime = uptime_secs; + s->count = 1; + } else if (s->count < 0xFFFF) { + s->count++; + } + s->advert_ts = advert_ts; + s->arrival_uptime = uptime_secs; + s->hops = hops; + return; + } + + for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) { + if (!_slots[i].used) { s = &_slots[i]; break; } + } + + if (!s) { + /* Hop-priority admission: a new sender may only displace a young + * (not tenure-eligible) entry farther than it; mature entries are + * protected unless silent > 24 h. Among candidates evict farthest, + * then lowest count, then stalest. (Naive LRU churns hub nodes to + * zero eligible voters.) */ + for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) { + Slot &c = _slots[i]; + bool young = !slotTenured(c, uptime_secs); + bool silent = (uptime_secs - c.arrival_uptime) > MATURE_SILENT_EVICT_SECS; + if (!young && !silent) continue; + if (c.hops <= hops) continue; + if (s == nullptr || + c.hops > s->hops || + (c.hops == s->hops && c.count < s->count) || + (c.hops == s->hops && c.count == s->count && + c.arrival_uptime < s->arrival_uptime)) { + s = &c; + } + } + if (!s) return; /* table full of closer/protected senders — drop */ + } + + memcpy(s->prefix, pubkey, sizeof(s->prefix)); + s->advert_ts = advert_ts; + s->arrival_uptime = uptime_secs; + s->first_uptime = uptime_secs; + s->count = 1; + s->hops = hops; + s->used = 1; +} + +MeshTimeSync::Consensus MeshTimeSync::computeConsensus(uint32_t local_time, + uint32_t uptime_secs, + bool bootstrap) const +{ + 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; + 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; + n++; + } + c.eligible = (uint8_t)n; + if (n == 0) return c; + + /* 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 + * touching intervals count as overlapping. */ + for (int i = 1; i < m; i++) { + int64_t v = val[i]; + int8_t t = typ[i]; + int j = i - 1; + while (j >= 0 && (val[j] > v || (val[j] == v && typ[j] < t))) { + val[j + 1] = val[j]; + typ[j + 1] = typ[j]; + j--; + } + val[j + 1] = v; + typ[j + 1] = t; + } + + int cur = 0, best = 0, best_idx = 0; + for (int i = 0; i < m; i++) { + cur += typ[i]; + if (typ[i] > 0 && cur > best) { + best = cur; + best_idx = i; + } + } + int64_t lo = val[best_idx]; + int64_t hi = val[best_idx + 1]; /* next endpoint always exists and, at the + * maximum, is an end — a start would have + * raised the count past `best` */ + + c.valid = true; + c.votes_for = (uint8_t)best; + c.votes_against = (uint8_t)(n - best); + c.mid = (lo + hi) / 2; + c.radius = (int32_t)((hi - lo) / 2); + return c; +} + +MeshTimeSync::Verdict MeshTimeSync::evaluateNow(uint32_t local_time, + uint32_t uptime_secs) const +{ + Verdict v; + memset(&v, 0, sizeof(v)); + + bool boot = isBootstrap(local_time); + v.bootstrap = boot; + v.consensus = computeConsensus(local_time, uptime_secs, boot); + + if (!v.consensus.valid) { + v.type = VERDICT_ABSTAIN; + v.reason = REASON_NO_DATA; + return v; + } + + if (boot) { + if (v.consensus.votes_for < BOOTSTRAP_QUORUM) { + v.type = VERDICT_ABSTAIN; + v.reason = REASON_NO_QUORUM; + return v; + } + /* Step to the cluster's low edge: from below, all later refinement + * is forward steps, which are always monotonicity-safe. */ + int64_t delta = v.consensus.mid - RADIUS_BASE_SECS; + if (delta < STEP_TRIGGER_SECS) { + v.type = VERDICT_NONE; + v.reason = REASON_IN_BAND; + return v; + } + if (isSuppressed(uptime_secs)) { /* suppression gates bootstrap too */ + v.type = VERDICT_NONE; + v.reason = REASON_SUPPRESSED; + return v; + } + if (_stepped_once && (uptime_secs - _last_step_uptime) < STEP_INTERVAL_SECS) { + v.type = VERDICT_NONE; + v.reason = REASON_RATE_LIMITED; + return v; + } + v.type = VERDICT_STEP; + v.delta = delta; /* bootstrap exempt from the ±1 h cap */ + return v; + } + + if (v.consensus.eligible < MESHTIMESYNC_QUORUM) { + v.type = VERDICT_ABSTAIN; + v.reason = REASON_NO_QUORUM; + return v; + } + if ((int)v.consensus.votes_for * 2 <= (int)v.consensus.eligible) { + v.type = VERDICT_ABSTAIN; + v.reason = REASON_NO_MAJORITY; + return v; + } + + int64_t mag = v.consensus.mid < 0 ? -v.consensus.mid : v.consensus.mid; + if (mag < STEP_TRIGGER_SECS) { + v.type = VERDICT_NONE; + v.reason = REASON_IN_BAND; + return v; + } + if (isSuppressed(uptime_secs)) { + v.type = VERDICT_NONE; + v.reason = REASON_SUPPRESSED; + return v; + } + if (_stepped_once && (uptime_secs - _last_step_uptime) < STEP_INTERVAL_SECS) { + v.type = VERDICT_NONE; + v.reason = REASON_RATE_LIMITED; + return v; + } + if (_pedigree) { + /* Physics veto: with a trusted sync + continuous uptime since, a + * real crystal cannot have drifted further than 300 ppm allows. */ + int64_t envelope = ((int64_t)(uptime_secs - _pedigree_uptime) * PEDIGREE_PPM) / 1000000 + + PEDIGREE_BASE_SECS; + if (mag > envelope) { + v.type = VERDICT_ABSTAIN; + v.reason = REASON_PEDIGREE; + return v; + } + } + + int64_t delta = v.consensus.mid; + if (delta > STEP_CAP_SECS) delta = STEP_CAP_SECS; + if (delta < -STEP_CAP_SECS) delta = -STEP_CAP_SECS; + v.type = VERDICT_STEP; + v.delta = delta; + return v; +} + +MeshTimeSync::Verdict MeshTimeSync::tick(uint32_t local_time, uint32_t uptime_secs) +{ + Verdict v; + if (_next_eval_uptime == 0 || uptime_secs < _next_eval_uptime) { + /* First call arms the pacing timer (skips a junk no-data abstain + * at boot); later calls no-op between evaluations. */ + if (_next_eval_uptime == 0) { + _next_eval_uptime = uptime_secs + EVAL_INTERVAL_SECS; + } + memset(&v, 0, sizeof(v)); + return v; + } + _next_eval_uptime = uptime_secs + EVAL_INTERVAL_SECS; + _evals++; + + v = evaluateNow(local_time, uptime_secs); + if (v.type == VERDICT_ABSTAIN) _abstains++; + return v; +} + +void MeshTimeSync::noteManualSync(uint32_t uptime_secs) +{ + _suppress_uptime = uptime_secs; + _suppressed = true; + _pedigree_uptime = uptime_secs; + _pedigree = true; +} + +void MeshTimeSync::noteGPSSync(uint32_t uptime_secs) +{ + _pedigree_uptime = uptime_secs; + _pedigree = true; +} + +void MeshTimeSync::noteStepApplied(int64_t delta, uint32_t local_time, + 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 +{ + return _suppressed && (uptime_secs - _suppress_uptime) < SUPPRESS_SECS; +} + +uint32_t MeshTimeSync::suppressRemaining(uint32_t uptime_secs) const +{ + if (!isSuppressed(uptime_secs)) return 0; + return SUPPRESS_SECS - (uptime_secs - _suppress_uptime); +} + +const char *MeshTimeSync::reasonStr(Reason r) +{ + switch (r) { + case REASON_IN_BAND: return "in-band"; + case REASON_NO_DATA: return "no-data"; + case REASON_NO_QUORUM: return "no-quorum"; + case REASON_NO_MAJORITY: return "no-majority"; + case REASON_SUPPRESSED: return "suppressed"; + case REASON_RATE_LIMITED: return "rate-limited"; + case REASON_PEDIGREE: return "pedigree-veto"; + default: return "-"; + } +} + +/* 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]; + if (v.type == VERDICT_STEP) { + snprintf(verdict, sizeof(verdict), "step%+ld%s", clampl(v.delta), + v.bootstrap ? " (bootstrap)" : ""); + } 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", + mag <= DEAD_BAND_SECS ? "ok" : "in-band"); + } else if (v.type == VERDICT_NONE) { + snprintf(verdict, sizeof(verdict), "hold (%s)", reasonStr(v.reason)); + } else { + snprintf(verdict, sizeof(verdict), "abstain (%s)", reasonStr(v.reason)); + } + + int pos = snprintf(out, cap, + "%s%s eligible=%u votes=%u/%u skew=%+lds r=%lds -> %s", + enabled ? "on" : "off", + enabled ? "" : " (dry-run)", + (unsigned)c.eligible, (unsigned)c.votes_for, + (unsigned)c.votes_against, + c.valid ? clampl(c.mid) : 0L, + c.valid ? (long)c.radius : 0L, + verdict); + 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)); + if (pos < 0 || (size_t)pos >= cap) goto full; + } + if (_backward_skips > 0) { + pos += snprintf(out + pos, cap - pos, " backskip=%lu", + (unsigned long)_backward_skips); + if (pos < 0 || (size_t)pos >= cap) goto full; + } + if (isSuppressed(uptime_secs)) { + pos += snprintf(out + pos, cap - pos, " sup=%luh", + (unsigned long)(suppressRemaining(uptime_secs) / 3600)); + if (pos < 0 || (size_t)pos >= cap) goto full; + } + pos += snprintf(out + pos, cap - pos, " evals=%lu abst=%lu", + (unsigned long)_evals, (unsigned long)_abstains); + if (pos < 0 || (size_t)pos >= cap) goto full; + + /* Evidence table: one entry per used slot, as many as fit. */ + for (int i = 0; i < MESHTIMESYNC_TABLE_SIZE; i++) { + const Slot &s = _slots[i]; + if (!s.used) continue; + int w = snprintf(out + pos, cap - pos, "\r\n %02x%02x h%u n%u %+lds%s", + s.prefix[0], s.prefix[1], (unsigned)s.hops, + (unsigned)(s.count > 99 ? 99 : s.count), + clampl(slotSkew(s, local_time, uptime_secs)), + slotEligible(s, uptime_secs) ? " E" : ""); + if (w < 0 || (size_t)(pos + w) >= cap) { + out[pos] = 0; /* drop the partial entry */ + return pos; + } + pos += w; + } + return pos; + +full: + out[cap - 1] = 0; + return (int)(cap - 1); +} diff --git a/zephcore/helpers/MeshTimeSync.h b/zephcore/helpers/MeshTimeSync.h new file mode 100644 index 0000000..51e88cc --- /dev/null +++ b/zephcore/helpers/MeshTimeSync.h @@ -0,0 +1,193 @@ +/* + * 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. + * + * All policy timers anchor on uptime, never wall clock, so the very steps + * they govern cannot distort them. + */ + +#pragma once + +#include +#include + +#ifdef CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE + #define MESHTIMESYNC_TABLE_SIZE CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE +#else + #define MESHTIMESYNC_TABLE_SIZE 32 +#endif + +#ifdef CONFIG_ZEPHCORE_TIMESYNC_QUORUM + #define MESHTIMESYNC_QUORUM CONFIG_ZEPHCORE_TIMESYNC_QUORUM +#else + #define MESHTIMESYNC_QUORUM 6 +#endif + +#ifndef FIRMWARE_BUILD_EPOCH + /* Injected by CMakeLists.txt (build-time UNIX epoch, the "provably dead + * clock" floor). 0 disables bootstrap mode entirely. */ + #define FIRMWARE_BUILD_EPOCH 0u +#endif + +class MeshTimeSync { +public: + static constexpr uint8_t HOP_CAP = 3; + static constexpr int32_t RADIUS_BASE_SECS = 150; + static constexpr int32_t RADIUS_PER_HOP_SECS = 15; + static constexpr uint32_t TENURE_SECS = 60 * 60; + static constexpr uint16_t TENURE_MIN_ADVERTS = 2; + static constexpr uint32_t MAX_SAMPLE_AGE_SECS = 5 * 24 * 3600; + static constexpr uint32_t MATURE_SILENT_EVICT_SECS = 24 * 3600; + static constexpr int64_t CONSISTENCY_BASE_SECS = 45; + static constexpr int64_t CONSISTENCY_PPM = 150; + static constexpr uint32_t EVAL_INTERVAL_SECS = 15 * 60; + static constexpr int64_t DEAD_BAND_SECS = 5 * 60; + static constexpr int64_t STEP_TRIGGER_SECS = 10 * 60; + static constexpr int64_t STEP_CAP_SECS = 3600; + static constexpr uint32_t STEP_INTERVAL_SECS = 6 * 3600; + static constexpr uint32_t SUPPRESS_SECS = 7 * 24 * 3600; + static constexpr int64_t PEDIGREE_PPM = 300; + static constexpr int64_t PEDIGREE_BASE_SECS = 10 * 60; + static constexpr uint8_t BOOTSTRAP_QUORUM = 3; + + /* 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 + * key, so a shorter prefix lets an attacker grind keypairs to collide + * with a tenured honest voter and reset its tenure with validly-signed + * adverts. If RAM ever matters, cut slot count instead. */ + struct Slot { + uint8_t prefix[8]; + uint32_t advert_ts; /* latest advert timestamp = the vote */ + uint32_t arrival_uptime; /* monotonic anchor: skew is recomputed at + * evaluate time, so a local clock step + * never stales stored samples */ + uint32_t first_uptime; /* tenure start */ + uint16_t count; /* adverts this tenure */ + uint8_t hops; /* precision hint only, never a trust signal */ + uint8_t used; + }; + + enum VerdictType : uint8_t { VERDICT_NONE = 0, VERDICT_ABSTAIN, VERDICT_STEP }; + + enum Reason : uint8_t { + REASON_NONE = 0, + REASON_IN_BAND, /* |skew| below the step trigger */ + REASON_NO_DATA, /* no eligible voters */ + REASON_NO_QUORUM, + REASON_NO_MAJORITY, + REASON_SUPPRESSED, /* manual clock set less than 7 days ago */ + REASON_RATE_LIMITED, /* < 6 h since last applied step */ + REASON_PEDIGREE, /* drift-envelope physics veto */ + }; + + struct Consensus { + bool valid; /* >= 1 eligible vote, intersection computed */ + uint8_t eligible; + uint8_t votes_for; /* votes inside the best intersection */ + uint8_t votes_against; + int64_t mid; /* consensus skew midpoint (+ = our clock is behind) */ + int32_t radius; /* intersection half-width */ + }; + + struct Verdict { + VerdictType type; + Reason reason; + int64_t delta; /* seconds to add to the clock (STEP only) */ + bool bootstrap; + Consensus consensus; + }; + + explicit MeshTimeSync(uint32_t build_epoch = 0) { reset(build_epoch); } + + void reset(uint32_t build_epoch); + + /* Feed one signature-verified advert. hops = flood path length (0 = heard + * direct). Samples beyond HOP_CAP are dropped. */ + 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); + + /* Unpaced consensus computation (CLI dry-run view). */ + Consensus computeConsensus(uint32_t local_time, uint32_t uptime_secs, + bool bootstrap) const; + + /* 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. */ + 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). */ + 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; } + 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. */ + 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: + Slot *findSlot(const uint8_t *prefix); + bool slotTenured(const Slot &s, uint32_t uptime_secs) const; + + Slot _slots[MESHTIMESYNC_TABLE_SIZE]; + uint32_t _build_epoch; + + /* Policy state — uptime-anchored (see header comment). */ + uint32_t _next_eval_uptime; + uint32_t _suppress_uptime; + bool _suppressed; + uint32_t _pedigree_uptime; + bool _pedigree; + uint32_t _last_step_uptime; + bool _stepped_once; + + /* Counters. */ + uint32_t _evals, _abstains, _steps, _bootstrap_steps, _backward_skips; + int64_t _last_step_delta; + uint32_t _last_step_wall; +}; diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 6745c76..9a15a17 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -64,6 +64,7 @@ struct NodePrefs { uint8_t rx_duty_cycle; // 1 = RX duty cycle, 0 = continuous RX uint8_t apc_enabled; // 1 = APC on, 0 = fixed TX power uint8_t apc_margin; // APC target link margin dB (6-30) + uint8_t meshtimesync; // 1 = mesh time-sync clock correction on (default off) /* ---- Companion-only fields ---- */ uint8_t manual_add_contacts; diff --git a/zephcore/helpers/time_sync.h b/zephcore/helpers/time_sync.h index 2c52f90..e758f28 100644 --- a/zephcore/helpers/time_sync.h +++ b/zephcore/helpers/time_sync.h @@ -17,6 +17,7 @@ enum time_sync_source { TIME_SYNC_APP, TIME_SYNC_WIFI, TIME_SYNC_CLI, + TIME_SYNC_MESH, /* mesh time-sync consensus step */ }; #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) || \ diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 78c4afc..e473d38 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -133,6 +133,7 @@ static char time_source_tag(enum time_sync_source src) case TIME_SYNC_GPS: return 'G'; /* GPS fix */ case TIME_SYNC_APP: return 'A'; /* phone/companion app */ case TIME_SYNC_WIFI: return 'N'; /* network (SNTP) */ + case TIME_SYNC_MESH: return 'M'; /* mesh time-sync consensus */ default: return 'L'; /* local: manual/CLI or stale/none */ } } diff --git a/zephcore/helpers/ui-joystick/time_sync.c b/zephcore/helpers/ui-joystick/time_sync.c index 7873d69..93f4d8f 100644 --- a/zephcore/helpers/ui-joystick/time_sync.c +++ b/zephcore/helpers/ui-joystick/time_sync.c @@ -17,6 +17,7 @@ static const char *source_short_name(enum time_sync_source src) case TIME_SYNC_APP: return "App"; case TIME_SYNC_WIFI: return "WiFi"; case TIME_SYNC_CLI: return "CLI"; + case TIME_SYNC_MESH: return "Mesh"; default: return NULL; } } diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 1314999..477c930 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -525,6 +525,12 @@ static void mesh_event_loop(void) mesh_housekeeping_ui_refresh(); + /* Mesh time-sync paced evaluation — loop() only runs on + * packet-driven events, so drive the 15-min tick here. */ + if (companion_mesh_ptr) { + companion_mesh_ptr->timeSyncTick(); + } + /* Low-battery auto-shutdown (companion only). Self-throttled * and compiled out unless the board sets a threshold — no * extra poll, just a cheap call on the existing tick. */ @@ -546,6 +552,12 @@ static void mesh_event_loop(void) * write here on the main thread instead. */ if (events & MESH_EVENT_RTC_SAVE) { zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); +#ifdef ZEPHCORE_LORA + /* GPS just set the clock — arm the mesh time-sync drift envelope. */ + if (companion_mesh_ptr) { + companion_mesh_ptr->noteGPSTimeSync(); + } +#endif } #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) @@ -703,6 +715,10 @@ public: int timeout_mins) override { (void)freq; (void)bw; (void)sf; (void)cr; (void)timeout_mins; } + + MeshTimeSync* getMeshTimeSync() override { + return companion_mesh.getMeshTimeSync(); + } }; static CompanionCLICallbacks companion_cli_cbs; diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index f7af7f7..a06d6b9 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -447,6 +447,12 @@ static void repeater_event_loop(void) * write here on the main thread instead. */ if (events & MESH_EVENT_RTC_SAVE) { zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); +#ifdef ZEPHCORE_LORA + /* GPS just set the clock — arm the mesh time-sync drift envelope. */ + if (repeater_mesh_ptr) { + repeater_mesh_ptr->noteGPSTimeSync(); + } +#endif } } } diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index 7f7eea5..dbbb12f 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -465,6 +465,12 @@ static void room_event_loop(void) * write here on the main thread instead. */ if (events & MESH_EVENT_RTC_SAVE) { zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); +#ifdef ZEPHCORE_LORA + /* GPS just set the clock — arm the mesh time-sync drift envelope. */ + if (room_mesh_ptr) { + room_mesh_ptr->noteGPSTimeSync(); + } +#endif } } }