Add edge clock sync diagnostics
This commit is contained in:
1 parent
836af4b623
commit
6c99ae39a9
6 files changed
+274
-76
No files matched your search
+49
-14
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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(×tamp, &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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user