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