Improve repeater LoRa clock synchronization

This commit is contained in:
mikecarper committed 2026-07-21 11:41:45 -07:00
1 parent 6562d15a90
commit 3897794416
4 files changed
+121 -53

No files matched your search

+29 -10
View File
@@ -590,6 +590,7 @@ send text.flood checking ridge link
- `get clock.sync.status`
- `get clock.sync.mesh`
- `set clock.sync.mesh <on|off>`
- `clock.sync.mesh now`
- `get clock.sync.internet`
- `set clock.sync.internet <on|off>`
- `get clock.sync.drift`
@@ -598,7 +599,7 @@ send text.flood checking ridge link
- `set clock.sync.samples <3-16>`
**Defaults:**
- `clock.sync.mesh`: `off`
- `clock.sync.mesh`: `on` for nRF52 repeaters; `off` for other builds
- `clock.sync.internet`: `off`
- `clock.sync.drift`: `3600` seconds
- `clock.sync.samples`: `9`
@@ -607,8 +608,22 @@ When either source is enabled, the repeater makes its first clock-bootstrap
attempt after 30 minutes of uptime. A successful estimate changes the RTC only
when the absolute difference is **greater than** `clock.sync.drift`; correction
can move the clock forward or backward. A valid estimate within the threshold
completes the one-time check without changing the clock. If no source is
available yet, the repeater retries every 30 minutes.
counts as a successful sync without changing the clock. Seven days after each
successful estimate, the repeater evaluates time again; the seven-day deadline
therefore starts from the last successful estimate rather than from boot. This
is a lazy uptime deadline: the check runs on the first normal loop/wake after it
becomes due and does not wake the device by itself. If no source or consensus is
available, the repeater retries every 30 minutes until one succeeds, then starts
a new seven-day interval. Every reboot starts with the initial 30-minute attempt.
An existing saved setting always overrides the platform default.
`clock.sync.mesh now` bypasses the startup/seven-day deadline and queues a
LoRa-only consensus evaluation on the next normal loop, even when the internet
source is also enabled. It uses any currently fresh samples without clearing the
16-slot table. If there is not yet enough evidence, mesh collection remains open
and the next attempt follows the normal 30-minute retry. The command requires
`clock.sync.mesh` to be on and does not bypass CLI, GPS, or NTP suppression; it
also retains the normal quorum, timestamp-validity, and drift checks.
`clock.sync.mesh` collects signature-verified advert timestamps and MAC-valid,
decrypted Public-channel plain-text timestamps, but only after the packet passes
@@ -626,6 +641,9 @@ succeed but an 8-vs-8 split cannot. The median is used. `clock.sync.samples`
accepts `3` through `16`; samples older than two hours are ignored. Status
reports `collecting` when fewer than the configured number of paths exist, and
`no consensus` when enough paths exist but the effective quorum does not agree.
Mesh collection begins immediately after boot. Following a successful estimate,
it resumes two hours before the next seven-day deadline so only evidence that
can still be fresh at evaluation time is processed.
A timestamp is eligible for a clock-sync sample only when it falls between the
UTC build epoch embedded by `build.sh` and that time plus ten calendar years.
@@ -664,15 +682,16 @@ prove that the advertising node's own clock is correct. Mesh time is therefore
a consensus estimate, not an authoritative time service.
`clock.sync.internet` is available on WiFi MQTT repeater-observer builds. Its
30-minute query runs on the MQTT/WiFi task and is read-only until the repeater
applies the configured drift test. On other repeater builds, the preference can
be stored but status reports that internet time is unavailable. MQTT builds
retain their existing startup NTP behavior required for MQTT/TLS/JWT operation;
this setting controls the additional delayed drift check. Startup NTP is always
preferred when it succeeds, regardless of this setting.
initial and seven-day queries run on the MQTT/WiFi task and are read-only until
the repeater applies the configured drift test. Failed queries retry after 30
minutes. On other repeater builds, the preference can be stored but status
reports that internet time is unavailable. MQTT builds retain their existing
startup NTP behavior required for MQTT/TLS/JWT operation; this setting controls
the additional delayed drift checks. Startup NTP is always preferred when it
succeeds, regardless of this setting.
Changing any `clock.sync.*` setting starts a new attempt for the current boot.
Settings are persistent in `/clock_sync`.
Settings are persistent in `/clock_sync`; samples and schedule state are not.
A backward correction is intentionally allowed, but peers that already recorded
a later timestamp from this node may temporarily reject its lower timestamps as
+2 -2
View File
@@ -86,8 +86,8 @@ set flood.retry.ignore none
| `flood.channel.scope` | Assigns a transport-region scope to received unscoped floods. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region>`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west` |
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter.1 0x0C all suspend=tempradio` |
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
| `clock.sync.mesh` | After 30 minutes of uptime, estimates UTC from a configurable consensus of fresh signed-advert or valid Public-channel sources. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `get clock.sync.status` | `set clock.sync.mesh on` |
| `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same delayed check on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet <on|off>` | `set clock.sync.internet on` |
| `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. After 30 minutes of uptime, it estimates UTC from a configurable consensus of fresh signed-advert or valid Public-channel sources, then repeats lazily seven days after each successful estimate. Failed attempts retry every 30 minutes; `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status` | `set clock.sync.mesh on` |
| `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same initial and seven-day checks on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet <on|off>` | `set clock.sync.internet on` |
| `clock.sync.drift` | Absolute correction threshold in seconds. The clock is moved forward or backward only when the estimate differs by more than this value. | `get clock.sync.drift`, `set clock.sync.drift <30-86400>` | `set clock.sync.drift 3600` |
| `clock.sync.samples` | Minimum number of distinct, in-window receive paths that must agree before mesh time can be used. A strict majority of all fresh samples is also required. Range `3-16`; default `9`. | `get clock.sync.samples`, `set clock.sync.samples <3-16>` | `set clock.sync.samples 9` |
| `outpath` | Overrides the primary direct route used for replies to the current remote client. | `get outpath`, `set outpath <hops>`, `set outpath direct`, `set outpath clear`, `set outpath flood` | `set outpath A1B2C3,D4E5F6` |
+80 -41
View File
@@ -2457,10 +2457,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes));
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
memset(clock_sync_samples, 0, sizeof(clock_sync_samples));
clock_sync_mesh_enabled = false;
clock_sync_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0;
clock_sync_internet_enabled = false;
clock_sync_complete = false;
clock_sync_internet_pending = false;
clock_sync_force_mesh_pending = false;
clock_sync_mesh_suppressed_by = CLOCK_SYNC_MESH_SUPPRESS_NONE;
clock_sync_last_result = CLOCK_SYNC_RESULT_WAITING;
clock_sync_last_source = CLOCK_SYNC_SOURCE_NONE;
@@ -5326,7 +5327,7 @@ void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) {
}
void MyMesh::loadClockSyncPrefs() {
clock_sync_mesh_enabled = false;
clock_sync_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0;
clock_sync_internet_enabled = false;
clock_sync_drift_seconds = CLOCK_SYNC_DRIFT_DEFAULT_SECONDS;
clock_sync_required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
@@ -5383,6 +5384,7 @@ bool MyMesh::saveClockSyncPrefs() {
void MyMesh::resetClockSyncAttempt() {
clock_sync_complete = false;
clock_sync_internet_pending = false;
clock_sync_force_mesh_pending = false;
clock_sync_last_result = CLOCK_SYNC_RESULT_WAITING;
clock_sync_last_source = CLOCK_SYNC_SOURCE_NONE;
clock_sync_last_sample_count = 0;
@@ -5409,6 +5411,7 @@ void MyMesh::suppressMeshClockSyncForBoot(uint8_t source) {
|| clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) return;
clock_sync_mesh_suppressed_by = source;
clock_sync_force_mesh_pending = false;
memset(clock_sync_samples, 0, sizeof(clock_sync_samples));
MESH_DEBUG_PRINTLN("Clock sync: LoRa estimate suppressed by %s until reboot",
clockSyncMeshSuppressionName(source));
@@ -5425,6 +5428,20 @@ void MyMesh::checkGpsClockSyncOverride() {
}
}
bool MyMesh::isClockSyncCollectionActive() const {
if (!clock_sync_mesh_enabled
|| clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) return false;
if (!clock_sync_complete) return true;
if (clock_sync_next_attempt_uptime == 0) return false;
if (uptime_millis >= clock_sync_next_attempt_uptime) return true;
// Keep only the evidence that could still be fresh at the next scheduled
// evaluation. This opens a two-hour rolling collection window before each
// seven-day re-sync without decrypting Public traffic for the entire week.
return clock_sync_next_attempt_uptime - uptime_millis
<= (uint64_t)CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS;
}
static void deriveClockSyncPathId(const mesh::Packet* packet,
uint8_t path_id[CLOCK_SYNC_PATH_ID_SIZE]) {
uint8_t material[2 + MAX_PATH_SIZE];
@@ -5466,8 +5483,7 @@ uint32_t MyMesh::estimateClockTransitMillis(const mesh::Packet* packet) const {
void MyMesh::recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[4], uint32_t epoch,
const mesh::Packet* packet) {
if (!clock_sync_mesh_enabled || clock_sync_complete
|| clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE || source_id == NULL
if (!isClockSyncCollectionActive() || source_id == NULL
|| packet == NULL || !packet->isRouteFlood() || !clockSyncEpochIsValid(epoch)) return;
uint32_t transit_millis = estimateClockTransitMillis(packet);
@@ -5536,8 +5552,7 @@ void MyMesh::recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[
}
void MyMesh::recordAcceptedFloodClockSample(const mesh::Packet* packet) {
if (!clock_sync_mesh_enabled || clock_sync_complete
|| clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE || packet == NULL) return;
if (!isClockSyncCollectionActive() || packet == NULL) return;
if (packet->getPayloadType() == PAYLOAD_TYPE_GRP_TXT) {
recordPublicChannelClockSample(packet);
@@ -5555,8 +5570,7 @@ void MyMesh::recordAcceptedFloodClockSample(const mesh::Packet* packet) {
}
void MyMesh::recordPublicChannelClockSample(const mesh::Packet* packet) {
if (!clock_sync_mesh_enabled || clock_sync_complete
|| clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) return;
if (!isClockSyncCollectionActive()) return;
uint32_t timestamp = 0;
char sender[FLOOD_GROUP_MODERATION_NAME_LEN];
if (!decodeFloodGroupPlainText(packet, FLOOD_PUBLIC_CHANNEL_SECRET, CIPHER_KEY_SIZE,
@@ -5620,7 +5634,7 @@ bool MyMesh::applyClockEstimate(uint32_t estimate, uint8_t source, uint8_t sampl
clock_sync_last_estimate = estimate;
clock_sync_last_abs_drift = magnitude > UINT32_MAX ? UINT32_MAX : (uint32_t)magnitude;
clock_sync_complete = true;
clock_sync_next_attempt_uptime = 0;
clock_sync_next_attempt_uptime = uptime_millis + CLOCK_SYNC_RESYNC_INTERVAL_MILLIS;
if (magnitude <= clock_sync_drift_seconds) {
clock_sync_last_result = CLOCK_SYNC_RESULT_WITHIN_DRIFT;
@@ -5667,37 +5681,49 @@ void MyMesh::checkClockSync() {
bool mesh_available = clock_sync_mesh_enabled
&& clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_NONE;
if (clock_sync_complete || (!mesh_available && !clock_sync_internet_enabled)
|| uptime_millis < clock_sync_next_attempt_uptime) return;
bool force_mesh = clock_sync_force_mesh_pending && mesh_available;
if ((!mesh_available && !clock_sync_internet_enabled)
|| (!force_mesh && uptime_millis < clock_sync_next_attempt_uptime)) return;
// A manual mesh request bypasses both the normal deadline and the optional
// internet-first probe once. It never bypasses the source safety latch.
if (force_mesh) clock_sync_force_mesh_pending = false;
// A successful estimate closes the current attempt but leaves a seven-day
// deadline behind. Reopen the attempt when that deadline arrives; failures
// below retain the existing 30-minute retry cadence until the next success.
if (clock_sync_complete) clock_sync_complete = false;
if (!force_mesh) {
#ifdef WITH_MQTT_BRIDGE
if (clock_sync_internet_enabled && mqtt_bridge != NULL && mqtt_bridge->isRunning()) {
if (clock_sync_internet_pending) {
uint32_t estimate = 0;
bool finished = false;
bool success = mqtt_bridge->takeNtpTimeEstimate(estimate, finished);
if (!finished && _ms->getMillis() - clock_sync_internet_requested_millis < 30000UL) return;
if (clock_sync_internet_enabled && mqtt_bridge != NULL && mqtt_bridge->isRunning()) {
if (clock_sync_internet_pending) {
uint32_t estimate = 0;
bool finished = false;
bool success = mqtt_bridge->takeNtpTimeEstimate(estimate, finished);
if (!finished && _ms->getMillis() - clock_sync_internet_requested_millis < 30000UL) return;
clock_sync_internet_pending = false;
if (success && applyClockEstimate(estimate, CLOCK_SYNC_SOURCE_INTERNET, 1)) return;
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
} else if (mqtt_bridge->requestNtpTimeEstimate()) {
clock_sync_internet_pending = true;
clock_sync_internet_requested_millis = _ms->getMillis();
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_PENDING;
return;
} else {
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
}
} else if (clock_sync_internet_enabled) {
clock_sync_internet_pending = false;
if (success && applyClockEstimate(estimate, CLOCK_SYNC_SOURCE_INTERNET, 1)) return;
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
} else if (mqtt_bridge->requestNtpTimeEstimate()) {
clock_sync_internet_pending = true;
clock_sync_internet_requested_millis = _ms->getMillis();
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_PENDING;
return;
} else {
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
}
} else if (clock_sync_internet_enabled) {
clock_sync_internet_pending = false;
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
}
#else
if (clock_sync_internet_enabled) {
clock_sync_internet_pending = false;
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
}
if (clock_sync_internet_enabled) {
clock_sync_internet_pending = false;
clock_sync_last_result = CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE;
}
#endif
}
if (mesh_available) {
uint32_t estimate = 0;
@@ -5755,23 +5781,24 @@ void MyMesh::formatClockSyncStatus(char* reply, size_t reply_len) const {
}
const char* source = clock_sync_last_source == CLOCK_SYNC_SOURCE_INTERNET ? "internet"
: (clock_sync_last_source == CLOCK_SYNC_SOURCE_MESH ? "mesh" : "none");
uint64_t remaining_ms = clock_sync_next_attempt_uptime > uptime_millis
? clock_sync_next_attempt_uptime - uptime_millis : 0;
unsigned long next_seconds = (unsigned long)((remaining_ms + 999ULL) / 1000ULL);
if (clock_sync_complete) {
if (clock_sync_last_source == CLOCK_SYNC_SOURCE_MESH) {
snprintf(reply, reply_len, "> %s %lus via %s epoch=%lu agree=%u/%u paths=%u mesh=%s",
snprintf(reply, reply_len, "> %s %lus via %s epoch=%lu agree=%u/%u paths=%u mesh=%s next=%lus",
result, (unsigned long)clock_sync_last_abs_drift, source,
(unsigned long)clock_sync_last_estimate,
(unsigned int)clock_sync_last_sample_count,
(unsigned int)clock_sync_last_required_count,
(unsigned int)clock_sync_last_fresh_count, mesh_state);
(unsigned int)clock_sync_last_fresh_count, mesh_state, next_seconds);
} else {
snprintf(reply, reply_len, "> %s %lus via %s, epoch=%lu drift=%lus mesh=%s",
snprintf(reply, reply_len, "> %s %lus via %s, epoch=%lu drift=%lus mesh=%s next=%lus",
result, (unsigned long)clock_sync_last_abs_drift, source,
(unsigned long)clock_sync_last_estimate,
(unsigned long)clock_sync_drift_seconds, mesh_state);
(unsigned long)clock_sync_drift_seconds, mesh_state, next_seconds);
}
} else {
uint64_t remaining_ms = clock_sync_next_attempt_uptime > uptime_millis
? clock_sync_next_attempt_uptime - uptime_millis : 0;
if (clock_sync_last_result == CLOCK_SYNC_RESULT_NO_CONSENSUS) {
snprintf(reply, reply_len,
"> no consensus, mesh=%s internet=%s agree=%u/%u paths=%u next=%lus",
@@ -5779,14 +5806,14 @@ void MyMesh::formatClockSyncStatus(char* reply, size_t reply_len) const {
(unsigned int)clock_sync_last_sample_count,
(unsigned int)clock_sync_last_required_count,
(unsigned int)clock_sync_last_fresh_count,
(unsigned long)((remaining_ms + 999ULL) / 1000ULL));
next_seconds);
} else {
snprintf(reply, reply_len, "> %s, mesh=%s internet=%s drift=%lus paths=%u/%u next=%lus",
result, mesh_state,
clock_sync_internet_enabled ? "on" : "off",
(unsigned long)clock_sync_drift_seconds, (unsigned int)fresh,
(unsigned int)clock_sync_required_samples,
(unsigned long)((remaining_ms + 999ULL) / 1000ULL));
next_seconds);
}
}
}
@@ -6564,6 +6591,18 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
sprintf(reply, "> %lu", (unsigned long)clock_sync_drift_seconds);
} else if (strcmp(command, "get clock.sync.samples") == 0) {
sprintf(reply, "> %u", (unsigned int)clock_sync_required_samples);
} else if (strcmp(command, "clock.sync.mesh now") == 0) {
if (!clock_sync_mesh_enabled) {
strcpy(reply, "Err - mesh clock sync is off");
} else if (clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) {
sprintf(reply, "Err - mesh sync suppressed by %s until reboot",
clockSyncMeshSuppressionName(clock_sync_mesh_suppressed_by));
} else {
resetClockSyncAttempt();
clock_sync_force_mesh_pending = true;
clock_sync_next_attempt_uptime = uptime_millis;
strcpy(reply, "OK - mesh clock sync queued");
}
} else if (strncmp(command, "set clock.sync.mesh ", 20) == 0) {
const char* value = command + 20;
bool enabled;
+10
View File
@@ -151,12 +151,20 @@ struct NeighbourInfo {
#ifndef CLOCK_SYNC_SAMPLE_SLOTS
#define CLOCK_SYNC_SAMPLE_SLOTS 16
#endif
#ifndef CLOCK_SYNC_MESH_DEFAULT_ENABLED
#if defined(NRF52_PLATFORM)
#define CLOCK_SYNC_MESH_DEFAULT_ENABLED 1
#else
#define CLOCK_SYNC_MESH_DEFAULT_ENABLED 0
#endif
#endif
#define CLOCK_SYNC_REQUIRED_SAMPLES_MIN 3
#define CLOCK_SYNC_REQUIRED_SAMPLES_MAX CLOCK_SYNC_SAMPLE_SLOTS
#define CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT 9
#define CLOCK_SYNC_PATH_ID_SIZE 8
#define CLOCK_SYNC_STARTUP_DELAY_MILLIS (30ULL * 60ULL * 1000ULL)
#define CLOCK_SYNC_RETRY_INTERVAL_MILLIS (30ULL * 60ULL * 1000ULL)
#define CLOCK_SYNC_RESYNC_INTERVAL_MILLIS (7ULL * 24ULL * 60ULL * 60ULL * 1000ULL)
#define CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS (2UL * 60UL * 60UL * 1000UL)
#define CLOCK_SYNC_CONSENSUS_WINDOW_SECONDS 600UL
#define CLOCK_SYNC_DRIFT_MIN_SECONDS 30UL
@@ -283,6 +291,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool clock_sync_internet_enabled;
bool clock_sync_complete;
bool clock_sync_internet_pending;
bool clock_sync_force_mesh_pending;
uint8_t clock_sync_mesh_suppressed_by;
uint8_t clock_sync_last_result;
uint8_t clock_sync_last_source;
@@ -451,6 +460,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
void resetClockSyncAttempt();
void suppressMeshClockSyncForBoot(uint8_t source);
void checkGpsClockSyncOverride();
bool isClockSyncCollectionActive() const;
uint32_t estimateClockTransitMillis(const mesh::Packet* packet) const;
void recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[4], uint32_t epoch,
const mesh::Packet* packet);