Add edge clock sync diagnostics

This commit is contained in:
mikecarper committed 2026-07-23 10:40:45 -07:00
1 parent 836af4b623
commit 6c99ae39a9
6 files changed
+274 -76

No files matched your search

+49 -14
View File
@@ -588,8 +588,12 @@ send text.flood checking ridge link
**Usage:**
- `get clock.sync`
- `get clock.sync.status`
- `get clock.sync.status.table`
- `get clock.sync.status.<1-16>`
- `get clock.sync.mesh`
- `set clock.sync.mesh <on|off>`
- `get clock.sync.mesh.edge`
- `set clock.sync.mesh.edge <on|off>`
- `clock.sync.mesh now`
- `get clock.sync.internet`
- `set clock.sync.internet <on|off>`
@@ -600,6 +604,7 @@ send text.flood checking ridge link
**Defaults:**
- `clock.sync.mesh`: `on` for nRF52 repeaters; `off` for other builds
- `clock.sync.mesh.edge`: `on`
- `clock.sync.internet`: `off`
- `clock.sync.drift`: `3600` seconds
- `clock.sync.samples`: `9`
@@ -633,17 +638,44 @@ a different full received path; all direct, zero-hop receptions count as the
same empty path. This prevents repeated packets or multiple names arriving over
one route from increasing the vote count.
At least the configured number of distinct fresh paths (nine by default) and a
strict majority of all fresh samples must fall within ten minutes of the
median. The effective quorum is therefore the larger of `clock.sync.samples`
and half the fresh sample count plus one. For example, a 9-vs-7 split can
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.
For a node at the edge of the network where every packet arrives through one
relay path, `set clock.sync.mesh.edge on` changes the evidence requirement from
distinct receive paths to distinct sources. Signature-verified adverts are
deduplicated by public key, and Public-channel timestamps are deduplicated by
case-insensitive display name. Repeated packets from one source still count
once. Changing edge mode clears the in-memory sample table so evidence collected
under the other policy is not reused. The setting is persistent and defaults on.
At least the configured number of distinct fresh evidence sources (nine by
default) and a strict majority of all fresh samples must fall within ten minutes
of the median. In normal mode each source must use a distinct receive path. In
edge mode signed adverts are distinct by public key and Public-channel messages
are distinct by display name, but all may use the same receive path. The
effective quorum is therefore the larger of
`clock.sync.samples` and half the fresh sample count plus one. For example, a
9-vs-7 split can 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 `mode=paths` or `mode=edge` and labels the collected
evidence as `paths` or `sources`. It reports `reason=need-more-paths` or
`reason=need-more-sources` when fewer than the configured number exist, and
`reason=no-consensus` when enough evidence exists 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.
`get clock.sync.status` reports whether the clock was set and a `reason` when it
was not. Common reasons include `waiting-deadline`, `need-more-sources`,
`need-more-paths`, `no-consensus`, `within-drift`, `mesh-off`, and suppression
by CLI, GPS, or internet time. It also reports whether collection is active, the
fresh evidence count, the number of occupied table slots, and the next
evaluation deadline.
`get clock.sync.status.table` shows the active sample table in compact form.
Each item is `slot:type:id-prefix:age`, where type `A` is a signed advert and
type `P` is a Public-channel message. A trailing `!` marks a stale sample. If
the compact reply is truncated, query any slot with
`get clock.sync.status.<1-16>`. The detail view reports the full source and path
hashes, age-adjusted epoch, difference from the local clock, and freshness.
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.
@@ -679,7 +711,9 @@ path hashes are also truncated, unauthenticated routing hints; requiring unique
paths prevents ordinary duplicate-route inflation but is not a cryptographic
identity check. Signed adverts authenticate the advert contents but do not
prove that the advertising node's own clock is correct. Mesh time is therefore
a consensus estimate, not an authoritative time service.
a consensus estimate, not an authoritative time service. Edge mode intentionally
gives up receive-path diversity. Public-channel display names can be spoofed, so
one sender can claim multiple names and inflate the edge-mode vote count.
`clock.sync.internet` is available on WiFi MQTT repeater-observer builds. Its
initial and seven-day queries run on the MQTT/WiFi task and are read-only until
@@ -700,9 +734,10 @@ replays until corrected time passes the previously observed value.
**Example:**
```text
set clock.sync.drift 1800
set clock.sync.samples 9
set clock.sync.samples 3
set clock.sync.mesh on
set clock.sync.internet on
set clock.sync.mesh.edge on
clock.sync.mesh now
get clock.sync.status
```
+3 -2
View File
@@ -86,10 +86,11 @@ 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` | 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.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. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
| `clock.sync.mesh.edge` | Defaults on so edge repeaters can collect clock evidence when all packets arrive through one relay path. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge 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` |
| `clock.sync.samples` | Minimum fresh evidence count before mesh time can be used: distinct receive paths in normal mode or distinct advert keys/Public-channel display names in edge mode. 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` |
| `altpath` | Adds a secondary direct route for repeater replies to the current remote client. | `get altpath`, `set altpath <hops>`, `set altpath direct`, `set altpath clear`, `set altpath flood` | `set altpath 71CE82,BA09F0` |
+200 -59
View File
@@ -2473,6 +2473,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
memset(clock_sync_samples, 0, sizeof(clock_sync_samples));
clock_sync_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0;
clock_sync_mesh_edge_enabled = CLOCK_SYNC_MESH_EDGE_DEFAULT_ENABLED != 0;
clock_sync_internet_enabled = false;
clock_sync_complete = false;
clock_sync_internet_pending = false;
@@ -5345,6 +5346,7 @@ void MyMesh::deleteFloodGroupModeration(const char* args, char* reply) {
void MyMesh::loadClockSyncPrefs() {
clock_sync_mesh_enabled = CLOCK_SYNC_MESH_DEFAULT_ENABLED != 0;
clock_sync_mesh_edge_enabled = CLOCK_SYNC_MESH_EDGE_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;
@@ -5354,16 +5356,22 @@ void MyMesh::loadClockSyncPrefs() {
if (file) {
uint8_t magic[4];
uint8_t mesh_enabled = 0;
uint8_t mesh_edge_enabled = CLOCK_SYNC_MESH_EDGE_DEFAULT_ENABLED != 0 ? 1 : 0;
uint8_t internet_enabled = 0;
uint8_t required_samples = CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
uint32_t drift_seconds = 0;
bool valid = file.read(magic, sizeof(magic)) == sizeof(magic);
valid = valid && memcmp(magic, "CTS3", sizeof(magic)) == 0
bool version3 = valid && memcmp(magic, "CTS3", sizeof(magic)) == 0;
bool version4 = valid && memcmp(magic, "CTS4", sizeof(magic)) == 0;
valid = valid && (version3 || version4)
&& file.read(&mesh_enabled, sizeof(mesh_enabled)) == sizeof(mesh_enabled)
&& file.read(&internet_enabled, sizeof(internet_enabled)) == sizeof(internet_enabled)
&& file.read((uint8_t*)&drift_seconds, sizeof(drift_seconds)) == sizeof(drift_seconds)
&& file.read(&required_samples, sizeof(required_samples)) == sizeof(required_samples);
valid = valid && mesh_enabled <= 1 && internet_enabled <= 1
if (valid && version4) {
valid = file.read(&mesh_edge_enabled, sizeof(mesh_edge_enabled)) == sizeof(mesh_edge_enabled);
}
valid = valid && mesh_enabled <= 1 && mesh_edge_enabled <= 1 && internet_enabled <= 1
&& drift_seconds >= CLOCK_SYNC_DRIFT_MIN_SECONDS
&& drift_seconds <= CLOCK_SYNC_DRIFT_MAX_SECONDS
&& required_samples >= CLOCK_SYNC_REQUIRED_SAMPLES_MIN
@@ -5371,6 +5379,7 @@ void MyMesh::loadClockSyncPrefs() {
file.close();
if (valid) {
clock_sync_mesh_enabled = mesh_enabled != 0;
clock_sync_mesh_edge_enabled = mesh_edge_enabled != 0;
clock_sync_internet_enabled = internet_enabled != 0;
clock_sync_drift_seconds = drift_seconds;
clock_sync_required_samples = required_samples;
@@ -5384,8 +5393,9 @@ bool MyMesh::saveClockSyncPrefs() {
if (_fs == NULL) return false;
File file = openFloodChannelBlockWrite(_fs, CLOCK_SYNC_PREFS_FILE);
if (!file) return false;
const uint8_t magic[4] = {'C', 'T', 'S', '3'};
const uint8_t magic[4] = {'C', 'T', 'S', '4'};
const uint8_t mesh_enabled = clock_sync_mesh_enabled ? 1 : 0;
const uint8_t mesh_edge_enabled = clock_sync_mesh_edge_enabled ? 1 : 0;
const uint8_t internet_enabled = clock_sync_internet_enabled ? 1 : 0;
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&mesh_enabled, sizeof(mesh_enabled)) == sizeof(mesh_enabled)
@@ -5393,7 +5403,8 @@ bool MyMesh::saveClockSyncPrefs() {
&& file.write((const uint8_t*)&clock_sync_drift_seconds,
sizeof(clock_sync_drift_seconds)) == sizeof(clock_sync_drift_seconds)
&& file.write(&clock_sync_required_samples,
sizeof(clock_sync_required_samples)) == sizeof(clock_sync_required_samples);
sizeof(clock_sync_required_samples)) == sizeof(clock_sync_required_samples)
&& file.write(&mesh_edge_enabled, sizeof(mesh_edge_enabled)) == sizeof(mesh_edge_enabled);
file.close();
return success;
}
@@ -5535,13 +5546,15 @@ void MyMesh::recordClockSyncSample(uint8_t source_kind, const uint8_t source_id[
&& memcmp(prior.path_id, path_id, sizeof(prior.path_id)) == 0) return;
}
// Every fresh vote must arrive through a distinct full received path. This
// also means only one zero-hop vote can be present at a time.
for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) {
const ClockSyncSample& sample = clock_sync_samples[i];
if (i == source_slot || !sample.active
|| now - sample.received_millis > CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) continue;
if (memcmp(sample.path_id, path_id, sizeof(sample.path_id)) == 0) return;
if (mesh::clockSyncRequiresUniquePath(clock_sync_mesh_edge_enabled)) {
// In normal mode every fresh vote must arrive through a distinct full
// received path. This also means only one zero-hop vote can be present.
for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) {
const ClockSyncSample& sample = clock_sync_samples[i];
if (i == source_slot || !sample.active
|| now - sample.received_millis > CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) continue;
if (memcmp(sample.path_id, path_id, sizeof(sample.path_id)) == 0) return;
}
}
int slot = source_slot;
@@ -5582,7 +5595,8 @@ void MyMesh::recordAcceptedFloodClockSample(const mesh::Packet* packet) {
mesh::Utils::sha256(source_id, sizeof(source_id), packet->payload, PUB_KEY_SIZE);
uint32_t timestamp = 0;
memcpy(&timestamp, &packet->payload[PUB_KEY_SIZE], sizeof(timestamp));
recordClockSyncSample(1, source_id, timestamp, packet);
recordClockSyncSample(mesh::CLOCK_SYNC_SAMPLE_SOURCE_SIGNED_ADVERT,
source_id, timestamp, packet);
}
}
@@ -5599,7 +5613,8 @@ void MyMesh::recordPublicChannelClockSample(const mesh::Packet* packet) {
}
uint8_t source_id[4];
mesh::Utils::sha256(source_id, sizeof(source_id), (const uint8_t*)sender, strlen(sender));
recordClockSyncSample(2, source_id, timestamp, packet);
recordClockSyncSample(mesh::CLOCK_SYNC_SAMPLE_SOURCE_PUBLIC_CHANNEL,
source_id, timestamp, packet);
}
bool MyMesh::estimateMeshClock(uint32_t& estimate, uint8_t& fresh_count,
@@ -5758,13 +5773,116 @@ void MyMesh::checkClockSync() {
clock_sync_next_attempt_uptime = uptime_millis + CLOCK_SYNC_RETRY_INTERVAL_MILLIS;
}
void MyMesh::formatClockSyncStatus(char* reply, size_t reply_len) const {
if (reply == NULL || reply_len == 0) return;
static const char* clockSyncSampleKindName(uint8_t source_kind) {
if (source_kind == mesh::CLOCK_SYNC_SAMPLE_SOURCE_SIGNED_ADVERT) return "advert";
if (source_kind == mesh::CLOCK_SYNC_SAMPLE_SOURCE_PUBLIC_CHANNEL) return "public";
return "unknown";
}
static char clockSyncSampleKindCode(uint8_t source_kind) {
if (source_kind == mesh::CLOCK_SYNC_SAMPLE_SOURCE_SIGNED_ADVERT) return 'A';
if (source_kind == mesh::CLOCK_SYNC_SAMPLE_SOURCE_PUBLIC_CHANNEL) return 'P';
return '?';
}
void MyMesh::formatClockSyncSampleDetail(int index, char* reply, size_t reply_len) const {
if (index < 0 || index >= CLOCK_SYNC_SAMPLE_SLOTS) {
snprintf(reply, reply_len, "Err - clock sample slot must be 1-%d", CLOCK_SYNC_SAMPLE_SLOTS);
return;
}
const ClockSyncSample& sample = clock_sync_samples[index];
if (!sample.active) {
snprintf(reply, reply_len, "> %d empty", index + 1);
return;
}
uint32_t age_seconds = (_ms->getMillis() - sample.received_millis) / 1000UL;
bool fresh = age_seconds <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS / 1000UL;
uint32_t current_epoch = sample.epoch;
if (age_seconds <= UINT32_MAX - current_epoch) current_epoch += age_seconds;
uint32_t local_epoch = getRTCClock()->getCurrentTime();
char delta_sign = current_epoch >= local_epoch ? '+' : '-';
uint32_t delta = current_epoch >= local_epoch
? current_epoch - local_epoch : local_epoch - current_epoch;
char source_id[sizeof(sample.source_id) * 2 + 1];
char path_id[sizeof(sample.path_id) * 2 + 1];
mesh::Utils::toHex(source_id, sample.source_id, sizeof(sample.source_id));
mesh::Utils::toHex(path_id, sample.path_id, sizeof(sample.path_id));
snprintf(reply, reply_len,
"> %d %s id=%s path=%s age=%lus epoch=%lu delta=%c%lus fresh=%s",
index + 1, clockSyncSampleKindName(sample.source_kind), source_id, path_id,
(unsigned long)age_seconds, (unsigned long)current_epoch, delta_sign,
(unsigned long)delta, fresh ? "yes" : "no");
}
void MyMesh::formatClockSyncTable(char* reply, size_t reply_len) const {
uint32_t now = _ms->getMillis();
uint8_t fresh = 0;
uint8_t active = 0;
for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) {
if (!clock_sync_samples[i].active) continue;
active++;
if (now - clock_sync_samples[i].received_millis <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) fresh++;
}
const char* mode = clock_sync_mesh_edge_enabled ? "edge" : "paths";
size_t used = (size_t)snprintf(reply, reply_len, "> %s collect=%s fresh=%u/%u",
mode, isClockSyncCollectionActive() ? "active" : "inactive",
(unsigned int)fresh,
(unsigned int)clock_sync_required_samples);
if (active == 0 || used >= reply_len) {
if (active == 0 && used + 5 < reply_len) StrHelper::strncpy(&reply[used], " none", reply_len - used);
return;
}
for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS && used + 1 < reply_len; i++) {
const ClockSyncSample& sample = clock_sync_samples[i];
if (!sample.active) continue;
uint32_t age_seconds = (now - sample.received_millis) / 1000UL;
unsigned long age_value = age_seconds < 120UL
? (unsigned long)age_seconds : (unsigned long)(age_seconds / 60UL);
char age_unit = age_seconds < 120UL ? 's' : 'm';
bool sample_fresh = age_seconds <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS / 1000UL;
char item[24];
snprintf(item, sizeof(item), " %d:%c:%02X%02X:%lu%c%s", i + 1,
clockSyncSampleKindCode(sample.source_kind), sample.source_id[0],
sample.source_id[1], age_value, age_unit, sample_fresh ? "" : "!");
size_t item_len = strlen(item);
if (used + item_len >= reply_len - 4) {
StrHelper::strncpy(&reply[used], " ...", reply_len - used);
return;
}
memcpy(&reply[used], item, item_len + 1);
used += item_len;
}
}
void MyMesh::formatClockSyncStatus(const char* args, char* reply, size_t reply_len) const {
if (reply == NULL || reply_len == 0) return;
const char* selector = skipLocalSpaces(args);
if (selector != NULL && *selector == '.') selector = skipLocalSpaces(selector + 1);
if (selector != NULL && *selector != 0) {
if (strcmp(selector, "table") == 0) {
formatClockSyncTable(reply, reply_len);
return;
}
int slot = 0;
if (parsePositiveSelector(selector, slot) && slot <= CLOCK_SYNC_SAMPLE_SLOTS) {
formatClockSyncSampleDetail(slot - 1, reply, reply_len);
return;
}
snprintf(reply, reply_len, "Err - use get clock.sync.status[.table|.1-.%d]",
CLOCK_SYNC_SAMPLE_SLOTS);
return;
}
uint8_t fresh = 0;
uint8_t active = 0;
uint32_t now = _ms->getMillis();
for (int i = 0; i < CLOCK_SYNC_SAMPLE_SLOTS; i++) {
if (clock_sync_samples[i].active
&& now - clock_sync_samples[i].received_millis <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) fresh++;
if (!clock_sync_samples[i].active) continue;
active++;
if (now - clock_sync_samples[i].received_millis <= CLOCK_SYNC_SAMPLE_MAX_AGE_MILLIS) fresh++;
}
bool mesh_available = clock_sync_mesh_enabled
&& clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_NONE;
@@ -5774,63 +5892,64 @@ void MyMesh::formatClockSyncStatus(char* reply, size_t reply_len) const {
? "suppressed-cli"
: (clock_sync_mesh_suppressed_by == CLOCK_SYNC_MESH_SUPPRESS_GPS
? "suppressed-gps" : "suppressed-internet")));
const char* mesh_mode = clock_sync_mesh_edge_enabled ? "edge" : "paths";
const char* evidence_name = clock_sync_mesh_edge_enabled ? "sources" : "paths";
bool collection_active = isClockSyncCollectionActive();
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 (!mesh_available && !clock_sync_internet_enabled) {
if (clock_sync_mesh_enabled) {
snprintf(reply, reply_len, "> mesh=%s until reboot, internet=off drift=%lus samples=%u",
mesh_state, (unsigned long)clock_sync_drift_seconds,
(unsigned int)clock_sync_required_samples);
return;
}
snprintf(reply, reply_len, "> off, drift=%lus", (unsigned long)clock_sync_drift_seconds);
const char* reason = clock_sync_mesh_enabled ? mesh_state : "mesh-off";
snprintf(reply, reply_len,
"> not-set reason=%s collect=inactive mode=%s %s=%u/%u table=%u",
reason, mesh_mode, evidence_name, (unsigned int)fresh,
(unsigned int)clock_sync_required_samples, (unsigned int)active);
return;
}
const char* result = "waiting";
switch (clock_sync_last_result) {
case CLOCK_SYNC_RESULT_COLLECTING: result = "collecting"; break;
case CLOCK_SYNC_RESULT_INTERNET_PENDING: result = "internet pending"; break;
case CLOCK_SYNC_RESULT_NO_CONSENSUS: result = "no consensus"; break;
case CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE: result = "internet unavailable"; break;
case CLOCK_SYNC_RESULT_WITHIN_DRIFT: result = "within drift"; break;
case CLOCK_SYNC_RESULT_CORRECTED_FORWARD: result = "corrected forward"; break;
case CLOCK_SYNC_RESULT_CORRECTED_BACKWARD: result = "corrected backward"; break;
case CLOCK_SYNC_RESULT_INTERNET_PENDING: result = "internet-pending"; break;
case CLOCK_SYNC_RESULT_NO_CONSENSUS: result = "no-consensus"; break;
case CLOCK_SYNC_RESULT_INTERNET_UNAVAILABLE: result = "internet-unavailable"; break;
case CLOCK_SYNC_RESULT_WITHIN_DRIFT: result = "within-drift"; break;
case CLOCK_SYNC_RESULT_CORRECTED_FORWARD: result = "corrected-forward"; break;
case CLOCK_SYNC_RESULT_CORRECTED_BACKWARD: result = "corrected-backward"; break;
default: break;
}
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 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, next_seconds);
} else {
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, next_seconds);
}
bool clock_was_set = clock_sync_last_result == CLOCK_SYNC_RESULT_CORRECTED_FORWARD
|| clock_sync_last_result == CLOCK_SYNC_RESULT_CORRECTED_BACKWARD;
snprintf(reply, reply_len,
"> %s reason=%s difference=%lus threshold=%lus via=%s collect=%s mode=%s table=%u next=%lus",
clock_was_set ? "set" : "not-set", result,
(unsigned long)clock_sync_last_abs_drift,
(unsigned long)clock_sync_drift_seconds, source,
collection_active ? "active" : "inactive", mesh_mode,
(unsigned int)active, next_seconds);
} else {
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",
mesh_state, clock_sync_internet_enabled ? "on" : "off",
"> not-set reason=no-consensus collect=%s mode=%s %s=%u agree=%u/%u table=%u next=%lus",
collection_active ? "active" : "inactive", mesh_mode, evidence_name,
(unsigned int)fresh,
(unsigned int)clock_sync_last_sample_count,
(unsigned int)clock_sync_last_required_count,
(unsigned int)clock_sync_last_fresh_count,
next_seconds);
(unsigned int)active, 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,
next_seconds);
const char* reason = result;
if (clock_sync_last_result == CLOCK_SYNC_RESULT_WAITING) reason = "waiting-deadline";
else if (clock_sync_last_result == CLOCK_SYNC_RESULT_COLLECTING) {
reason = clock_sync_mesh_edge_enabled ? "need-more-sources" : "need-more-paths";
}
snprintf(reply, reply_len,
"> not-set reason=%s collect=%s mesh=%s mode=%s %s=%u/%u table=%u next=%lus",
reason, collection_active ? "active" : "inactive", mesh_state, mesh_mode,
evidence_name, (unsigned int)fresh,
(unsigned int)clock_sync_required_samples, (unsigned int)active, next_seconds);
}
}
}
@@ -6595,9 +6714,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
setFloodGroupModeration(command + strlen("set flood.moderation"), reply);
} else if (commandFamilyMatches(command, "del flood.moderation")) {
deleteFloodGroupModeration(command + strlen("del flood.moderation"), reply);
} else if (strcmp(command, "get clock.sync") == 0
|| strcmp(command, "get clock.sync.status") == 0) {
formatClockSyncStatus(reply, 160);
} else if (commandFamilyMatches(command, "get clock.sync.status")) {
formatClockSyncStatus(command + strlen("get clock.sync.status"), reply, 160);
} else if (strcmp(command, "get clock.sync") == 0) {
formatClockSyncStatus("", reply, 160);
} else if (strcmp(command, "get clock.sync.mesh") == 0) {
if (clock_sync_mesh_enabled
&& clock_sync_mesh_suppressed_by != CLOCK_SYNC_MESH_SUPPRESS_NONE) {
@@ -6606,6 +6726,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
} else {
sprintf(reply, "> %s", clock_sync_mesh_enabled ? "on" : "off");
}
} else if (strcmp(command, "get clock.sync.mesh.edge") == 0) {
sprintf(reply, "> %s", clock_sync_mesh_edge_enabled ? "on" : "off");
} else if (strcmp(command, "get clock.sync.internet") == 0) {
#ifdef WITH_MQTT_BRIDGE
sprintf(reply, "> %s", clock_sync_internet_enabled ? "on" : "off");
@@ -6652,6 +6774,25 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
strcpy(reply, enabled ? "OK - mesh clock sync enabled" : "OK - mesh clock sync disabled");
}
}
} else if (strncmp(command, "set clock.sync.mesh.edge ", 25) == 0) {
const char* value = command + 25;
bool enabled;
if (strcmp(value, "on") == 0) enabled = true;
else if (strcmp(value, "off") == 0) enabled = false;
else {
strcpy(reply, "Err - usage: set clock.sync.mesh.edge <on|off>");
return;
}
bool previous = clock_sync_mesh_edge_enabled;
clock_sync_mesh_edge_enabled = enabled;
if (!saveClockSyncPrefs()) {
clock_sync_mesh_edge_enabled = previous;
strcpy(reply, "Err - unable to save clock sync settings");
} else {
if (enabled != previous) memset(clock_sync_samples, 0, sizeof(clock_sync_samples));
resetClockSyncAttempt();
strcpy(reply, enabled ? "OK - edge clock sync enabled" : "OK - edge clock sync disabled");
}
} else if (strncmp(command, "set clock.sync.internet ", 24) == 0) {
const char* value = command + 24;
bool enabled;
+7 -1
View File
@@ -158,6 +158,9 @@ struct NeighbourInfo {
#define CLOCK_SYNC_MESH_DEFAULT_ENABLED 0
#endif
#endif
#ifndef CLOCK_SYNC_MESH_EDGE_DEFAULT_ENABLED
#define CLOCK_SYNC_MESH_EDGE_DEFAULT_ENABLED 1
#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
@@ -288,6 +291,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS];
bool clock_sync_mesh_enabled;
bool clock_sync_mesh_edge_enabled;
bool clock_sync_internet_enabled;
bool clock_sync_complete;
bool clock_sync_internet_pending;
@@ -470,7 +474,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t& agreeing_count, uint8_t& required_count) const;
bool applyClockEstimate(uint32_t estimate, uint8_t source, uint8_t sample_count);
void checkClockSync();
void formatClockSyncStatus(char* reply, size_t reply_len) const;
void formatClockSyncStatus(const char* args, char* reply, size_t reply_len) const;
void formatClockSyncTable(char* reply, size_t reply_len) const;
void formatClockSyncSampleDetail(int index, char* reply, size_t reply_len) const;
int findFloodChannelBlockBySelector(const char* selector) const;
int findFloodChannelBlockSlot(const uint8_t prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN], const char* name) const;
void formatFloodChannelBlockDetail(char* reply, int idx) const;
+7
View File
@@ -4,6 +4,13 @@
namespace mesh {
static constexpr uint8_t CLOCK_SYNC_SAMPLE_SOURCE_SIGNED_ADVERT = 1;
static constexpr uint8_t CLOCK_SYNC_SAMPLE_SOURCE_PUBLIC_CHANNEL = 2;
inline bool clockSyncRequiresUniquePath(bool edge_mode) {
return !edge_mode;
}
struct ClockSyncConsensusResult {
bool consensus;
uint8_t fresh_count;
@@ -146,6 +146,14 @@ TEST(ClockSyncConsensus, ConfiguredEightStillCannotAcceptAnEightVsEightSplit) {
EXPECT_EQ(9, result.required_count);
}
TEST(ClockSyncPathPolicy, NormalModeRequiresUniquePaths) {
EXPECT_TRUE(mesh::clockSyncRequiresUniquePath(false));
}
TEST(ClockSyncPathPolicy, EdgeModeAllowsOnePath) {
EXPECT_FALSE(mesh::clockSyncRequiresUniquePath(true));
}
static mesh::Packet* makeTrace(TraceTestMesh& node, uint32_t tag, uint32_t auth,
const uint8_t* route, uint8_t route_len) {
mesh::Packet* packet = node.createTrace(tag, auth, 0);