meshtimesync p1

This commit is contained in:
liquidraver
2026-07-03 09:11:43 +02:00
parent fc940d27ba
commit 14cb8c61f9
30 changed files with 1231 additions and 3 deletions
+2
View File
@@ -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/
+38
View File
@@ -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
+7
View File
@@ -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
)
+25
View File
@@ -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
+84
View File
@@ -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 <epoch>` or `clock sync` — any
manual set protects the clock from automatic changes for 7 days.
- It never steps more than 1 hour at a time, and at most one step per 6 hours.
- It does nothing unless at least 6 trustworthy senders are visible and a
strict majority of them agree (exception: a provably dead clock after a
reboot needs only 3 agreeing senders to recover).
- Room servers and companions never step backward (that would break message
ordering / get their messages dropped as replays); a backward verdict is
only reported in `get meshtimesync`.
## Drawbacks and honest limitations
- Small meshes (fewer than ~7 advert-active nodes in range) mostly won't
reach quorum, so normal correction stays inactive — dead-clock recovery
after a reboot still works (needs only 3).
- If most nodes around you share the SAME wrong time (it happens — one
region of the live mesh has a 60+ node island that is 28 hours fast), your
node may step toward them. The damage is bounded to 4 hours per day and
every step is visible in the logs and in `get meshtimesync`.
- A backward step mutes this node's own adverts at peers for a window equal
to the step size. It self-heals as their clocks pass the old mark, or
delete + re-add the contact in the app to recover instantly.
- Worst case — a coordinated wrong majority around you: turn it off
(`set meshtimesync off`) and set the clock manually, which also arms the
7-day protection.
## Recovery recipe
If the clock ever ends up AHEAD of true time (`time` alone will refuse with
"clock cannot go backwards"):
```
set meshtimesync off
clkreboot
(log back in)
time <correct epoch>
set meshtimesync on
```
Works over remote admin too. The off/on bracket makes it deterministic (no
bootstrap race), and the manual set arms the 7-day protection anyway. Fresh
adverts take tens of minutes to accumulate after a reboot, so an operator
syncing right after login always wins.
For the design details (consensus algorithm, security model, why the limits
are what they are), see [ARCHITECTURE.md](ARCHITECTURE.md) section 4.9.
+4 -2
View File
@@ -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 <unix_timestamp>` | 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 <unix_timestamp>` | 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 <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 prv.key <hex>` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* |
---
@@ -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'",
+68
View File
@@ -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);
}
+18
View File
@@ -6,9 +6,11 @@
#pragma once
#include <helpers/BaseChatMesh.h>
#include <helpers/MeshTimeSync.h>
#include <helpers/TransportKeyStore.h>
#include <ZephyrDataStore.h>
#include <NodePrefs.h>
#include <zephyr/kernel.h>
/* 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;
+92
View File
@@ -9,6 +9,8 @@
#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>
@@ -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(&timestamp, &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], &timestamp, 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");
+15
View File
@@ -14,6 +14,7 @@
#include <mesh/Identity.h>
#include <mesh/RNG.h>
#include <mesh/RTC.h>
#include <helpers/MeshTimeSync.h>
#include <helpers/NodePrefs.h>
#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; }
+5
View File
@@ -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);
+66
View File
@@ -12,6 +12,8 @@
#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>
@@ -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;
}
+8
View File
@@ -18,6 +18,7 @@
#include <mesh/SimpleMeshTables.h>
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
#include <helpers/MeshTimeSync.h>
#include <helpers/RegionMap.h>
#include <helpers/TransportKeyStore.h>
#include <helpers/RateLimiter.h>
@@ -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();
+61
View File
@@ -19,6 +19,8 @@
#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>
@@ -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;
}
+10
View File
@@ -19,6 +19,7 @@
#include <mesh/SimpleMeshTables.h>
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
#include <helpers/MeshTimeSync.h>
#include <helpers/RegionMap.h>
#include <helpers/TransportKeyStore.h>
#include <helpers/RateLimiter.h>
@@ -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();
+12
View File
@@ -173,11 +173,13 @@ static void print_banner(void)
cli_println("set sf <7-12> Spreading factor");
cli_println("set bw <idx> Bandwidth: 3=62.5 0=125 1=250 2=500 kHz");
cli_println("set cr <5-8> Coding rate");
cli_println("set meshtimesync <on|off> 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");
+2
View File
@@ -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)",
+5
View File
@@ -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; }
+36
View File
@@ -5,6 +5,7 @@
#include "CommonCLI.h"
#include "battery_curve.h"
#include <helpers/MeshTimeSync.h>
#include <helpers/time_sync.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <helpers/TxtDataHelpers.h>
@@ -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);
}
+5
View File
@@ -12,6 +12,8 @@
#include <helpers/ClientACL.h>
#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; }
+432
View File
@@ -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 <stdio.h>
#include <string.h>
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);
}
+193
View File
@@ -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 <stdint.h>
#include <stddef.h>
#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;
};
+1
View File
@@ -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;
+1
View File
@@ -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) || \
+1
View File
@@ -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 */
}
}
+1
View File
@@ -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;
}
}
+16
View File
@@ -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;
+6
View File
@@ -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
}
}
}
+6
View File
@@ -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
}
}
}