Add repeater filtering, clock sync, and companion fixes

This commit is contained in:
mikecarper committed 2026-07-15 17:40:20 -07:00
1 parent 0d2eb2420c
commit 63776482e1
30 files changed
+2757 -65

No files matched your search

+9 -2
View File
@@ -360,6 +360,11 @@ prompt_for_debug_build_settings() {
}
prompt_for_mqtt_bridge_build_setting() {
if [ -n "$SELECTED_TARGET" ] && [ "$(get_platform_for_env "$SELECTED_TARGET")" == "NRF52_PLATFORM" ]; then
MQTT_BRIDGE_OVERRIDE="off"
return 0
fi
if [ -n "$SELECTED_TARGET" ] && is_mqtt_bridge_target "$SELECTED_TARGET"; then
MQTT_BRIDGE_OVERRIDE="on"
echo "MQTT bridge enabled by selected target: ${SELECTED_TARGET}"
@@ -1726,6 +1731,7 @@ build_firmware() {
local env_platform
local commit_hash
local firmware_build_date
local firmware_build_epoch
local firmware_version
local firmware_version_string
local firmware_filename
@@ -1742,7 +1748,8 @@ build_firmware() {
fi
commit_hash=$(git rev-parse --short HEAD)
firmware_build_date=$(date '+%d-%b-%Y')
firmware_build_date=$(date -u '+%d-%b-%Y')
firmware_build_epoch=$(date -u '+%s')
firmware_version=${FIRMWARE_VERSION:-}
if [ -z "$firmware_version" ]; then
@@ -1796,7 +1803,7 @@ build_firmware() {
original_platformio_build_flags=""
fi
export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}"
export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}"
disable_debug_flags
apply_debug_overrides
apply_mqtt_bridge_override
+249
View File
@@ -13,6 +13,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore
- [Radio](#radio)
- [System](#system)
- [Routing](#routing)
- [Flood Filtering](#filter-flood-packets-by-payload-type-and-hop)
- [Group Text Moderation](#moderate-flood-group-text-by-channel-sender-and-source-path)
- [ACL](#acl)
- [Region Management](#region-management-v110)
- [Region Examples](#region-examples)
@@ -518,6 +520,112 @@ send text.flood checking ridge link
---
#### Estimate and correct repeater time after startup
**Usage:**
- `get clock.sync`
- `get clock.sync.status`
- `get clock.sync.mesh`
- `set clock.sync.mesh <on|off>`
- `get clock.sync.internet`
- `set clock.sync.internet <on|off>`
- `get clock.sync.drift`
- `set clock.sync.drift <30-86400>`
- `get clock.sync.samples`
- `set clock.sync.samples <3-16>`
**Defaults:**
- `clock.sync.mesh`: `off`
- `clock.sync.internet`: `off`
- `clock.sync.drift`: `3600` seconds
- `clock.sync.samples`: `9`
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.
`clock.sync.mesh` collects signature-verified advert timestamps and MAC-valid,
decrypted Public-channel plain-text timestamps, but only after the packet passes
every forwarding filter. Sources are deduplicated by advert public key or
case-insensitive Public-channel display name. Every fresh sample must also have
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.
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.
Direct developer builds that bypass `build.sh` fall back to the compiler
timestamp. Validation happens before a slot is selected or written, so an
advert or Public-channel timestamp outside that window is not recorded as a
clock sample.
Before voting, the node advances each packet timestamp by an estimated transit
time. The estimate sums the radio airtime at the original packet length and at
each progressively longer relay length, plus the expected midpoint of the
random flood-forward delay at every prior hop. The normal elapsed time since
the radio recorded local receipt is then added when consensus is evaluated, so
local signature/decryption/filter processing time is included too. This is
better than using hop count alone because LoRa airtime changes with packet
length and radio settings. Transit compensation is capped at the ten-minute
consensus window. It cannot know sender queueing, channel contention, or a
remote relay's non-matching `txdelay`, so the consensus window and median still
absorb residual error.
If a `clock sync` or `time <epoch>` CLI command successfully sets the clock, or
a GPS provider writes a valid GPS time, LoRa-derived clock collection and
correction are suppressed for the rest of that boot. Turning
`clock.sync.mesh` off and back on does not clear this safety latch; only a
reboot does. `get clock.sync.mesh` and `get clock.sync.status` report whether
CLI or GPS time caused the suppression. On a WiFi MQTT build, a successful NTP
sync is authoritative and also suppresses LoRa correction for the rest of that
boot. Source selection starts fresh after a reboot, so LoRa remains the fallback
when NTP cannot obtain internet time during that boot.
Public-channel display names are not authenticated and can be spoofed. Received
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.
`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.
Changing any `clock.sync.*` setting starts a new attempt for the current boot.
Settings are persistent in `/clock_sync`.
A backward correction is intentionally allowed, but peers that already recorded
a later timestamp from this node may temporarily reject its lower timestamps as
replays until corrected time passes the previously observed value.
**Example:**
```text
set clock.sync.drift 1800
set clock.sync.samples 9
set clock.sync.mesh on
set clock.sync.internet on
get clock.sync.status
```
---
#### View this node's public key
**Usage:** `get public.key`
@@ -924,6 +1032,135 @@ del flood.channel.block.2
---
#### Filter flood packets by payload type and hop
For setup guidance, interactions with the existing forwarding controls, and
worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md).
**Usage:**
- `get flood.filter`
- `get flood.filter.<n>`
- `set flood.filter <type> <hops>`
- `set flood.filter.<n> <type> <hops>`
- `del flood.filter.<n>`
- `del flood.filter all`
**Parameters:**
- `n`: Rule slot from `1` to `16`.
- `type`: Payload type name, full `PAYLOAD_TYPE_*` name, numeric value `0-15`, or `any`.
- `hops`:
- `N`: Block only at received hop count `N`.
- `N+`: Block at received hop count `N` and higher.
- `N-M`: Block the inclusive received-hop range.
- `all`: Block at every received hop count (`0-63`).
The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/):
| Value | Short name | Full name |
| --- | --- | --- |
| `0x00` | `req` | `PAYLOAD_TYPE_REQ` |
| `0x01` | `response` | `PAYLOAD_TYPE_RESPONSE` |
| `0x02` | `txt_msg` | `PAYLOAD_TYPE_TXT_MSG` |
| `0x03` | `ack` | `PAYLOAD_TYPE_ACK` |
| `0x04` | `advert` | `PAYLOAD_TYPE_ADVERT` |
| `0x05` | `grp_txt` | `PAYLOAD_TYPE_GRP_TXT` |
| `0x06` | `grp_data` | `PAYLOAD_TYPE_GRP_DATA` |
| `0x07` | `anon_req` | `PAYLOAD_TYPE_ANON_REQ` |
| `0x08` | `path` | `PAYLOAD_TYPE_PATH` |
| `0x09` | `trace` | `PAYLOAD_TYPE_TRACE` |
| `0x0A` | `multipart` | `PAYLOAD_TYPE_MULTIPART` |
| `0x0B` | `control` | `PAYLOAD_TYPE_CONTROL` |
| `0x0C` | `ota` | `PAYLOAD_TYPE_OTA` (this fork's LoRa OTA extension; reserved upstream) |
| `0x0D` | `13` | reserved |
| `0x0E` | `14` | reserved |
| `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` |
**Route scope:** Rules are evaluated only for the two flood route values:
`ROUTE_TYPE_TRANSPORT_FLOOD` (`0x00`, flood plus transport codes) and
`ROUTE_TYPE_FLOOD` (`0x01`, unscoped flood). Direct routes `0x02` and `0x03`
are never affected.
**Behavior:** A match prevents retransmission by this repeater. The packet is
still received and can still be logged. Rules are persistent and default to an
empty table, so upgrading does not change forwarding. A malformed persisted
table fails open (no general rules are applied).
Without `.n`, `set` returns an identical existing rule or uses the first empty
slot. With `.n`, it replaces that slot. `get flood.filter` gives a compact list;
use `get flood.filter.<n>` for full details.
**Examples:**
```text
set flood.filter grp_data 4+
set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+
set flood.filter ota 2-4
set flood.filter any 12+
get flood.filter
get flood.filter.2
del flood.filter.2
```
---
#### Moderate flood group text by channel, sender, and source path
**Usage:**
- `get flood.moderation`
- `get flood.moderation.<n>`
- `set flood.moderation <channel> <sender> <action> [action...]`
- `set flood.moderation.<n> <channel> <sender> <action> [action...]`
- `del flood.moderation.<n>`
- `del flood.moderation all`
**Parameters:**
- `n`: Moderation slot from `1` to `16`.
- `channel`:
- `public`: Built-in Public channel.
- `#channel`: Derive the well-known hashtag-channel key.
- A 128-bit or 256-bit channel key in hex, for any other/private channel.
- `sender`: Exact group-text display name. Matching is ASCII case-insensitive.
Quote names containing spaces, for example `"Field User"`.
- `drop`: Do not forward matching messages. Equivalent to `rate=0/min`.
- `rate=X/min`: Forward at most `X` matching messages per 60-second local
window. This option requires an exact sender rather than `*`.
- `hops=N`: Do not forward a matching message whose received flood path count
is `N` or higher. `hops=all` removes this constraint.
- `path=H1[,H2,H3]`: Match the start of the flood path. One to three hashes are
accepted; every hash must have the same 1-, 2-, or 3-byte width.
- `path=*`: Match any source path (the default).
At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop
limits can be combined. Rate counters are local to this repeater and rule, use a
60-second window beginning with the first matching message, and reset on reboot.
**Decode and identity behavior:** Moderation applies only to flood
`PAYLOAD_TYPE_GRP_TXT`. The repeater first checks the packet's channel-hash byte,
then validates and decrypts with the configured key. It extracts the text before
the first `:` from the standard `<sender>: <message>` plaintext. The channel key
is stored locally but is never printed by `get`.
The group-text sender is an **unverified display name**, not a public key. It can
be spoofed. Combining it with the first one to three path hashes makes a more
useful moderation signal, but path hashes are truncated and are not proof of the
originating user. A path-qualified rule begins matching only after the packet
contains all configured starting hops; it cannot identify a first hop on a
zero-hop packet.
As with general filtering, matching messages are still received/logged; only
retransmission is denied. There are no moderation rules by default.
**Examples:**
```text
set flood.moderation public "Noisy User" rate=5/min
set flood.moderation #local bot drop path=A1B2C3,D4E5F6
set flood.moderation.3 00112233445566778899AABBCCDDEEFF alice rate=10/min hops=4 path=71CE82
get flood.moderation
get flood.moderation.3
del flood.moderation.3
```
---
### ACL
#### Add, update or remove permissions for a companion
@@ -937,6 +1174,18 @@ del flood.channel.block.2
- `1`: Read-only
- `2`: Read-write
- `3`: Admin
- `4`: Region manager (repeater delegated region management)
- `5`: Filter manager (repeater delegated forwarding-filter management)
**Filter manager scope:** Permission `5` can use an explicit allowlist of
non-secret operational/filter status commands and can change the forwarding
controls `repeat`, `loop.detect`, `flood.max*`, `flood.channel.data*`,
`flood.channel.block*`, `flood.filter*`, and `flood.moderation*`. It cannot read
guest, WiFi, MQTT, bridge, or other credentials, and it cannot change regions,
ACL entries, radio settings, or other admin configuration. Permission `4`
remains limited to region commands and the same non-secret status allowlist.
Both delegated manager roles are protected from least-recently-active ACL
eviction like administrators.
**Note:** Removes the entry when `permissions` is omitted
+227
View File
@@ -0,0 +1,227 @@
# Repeater Flood Filtering and Moderation
This guide explains the Keymind repeater forwarding filters. The filters decide
whether this repeater retransmits a packet. They do not stop local reception,
packet logging, or MQTT observation.
Only flood routes are filtered:
- `0x00` / `ROUTE_TYPE_TRANSPORT_FLOOD` — flood routing with transport codes
- `0x01` / `ROUTE_TYPE_FLOOD` — unscoped flood routing
Direct routes `0x02` and `0x03` are never affected by these rules.
The route and payload values follow the upstream
[packet-format reference](https://docs.meshcore.io/packet_format/) and
[payload layouts](https://docs.meshcore.io/payloads/), with this fork's LoRa
OTA assignment noted below.
## Before making changes
Show the current forwarding controls:
```text
get repeat
get flood.max
get flood.max.unscoped
get flood.max.advert
get flood.channel.data
get flood.channel.data.hops
get flood.channel.block
get flood.filter
get flood.moderation
```
The `flood.filter` and `flood.moderation` tables each have 16 persistent slots.
They are empty by default. A corrupt or truncated table fails open, so corrupt
storage does not silently enable blocking.
## Filter by payload type and received hop count
Use `flood.filter` when the packet type and its current path length are enough
to make the decision:
```text
set flood.filter <type> <hops>
set flood.filter.<slot> <type> <hops>
get flood.filter
get flood.filter.<slot>
del flood.filter.<slot>
del flood.filter all
```
Without a slot number, `set` reuses an identical rule or selects the first empty
slot. With a slot number, it replaces that slot.
Hop expressions are based on the path count when this repeater receives the
packet:
- `N` matches exactly `N` received hops.
- `N+` matches `N` or more received hops.
- `N-M` matches the inclusive range.
- `all` matches `0` through `63` hops.
Examples:
```text
# Stop forwarding group data once it arrives with four or more path entries.
set flood.filter grp_data 4+
# Stop long adverts, while still allowing shorter adverts.
set flood.filter.2 advert 6+
# Keep LoRa OTA floods from crossing this repeater at path counts 2 through 4.
set flood.filter.3 ota 2-4
# Apply a hard ceiling to every flood payload type.
set flood.filter any 12+
```
Accepted payload names are:
| Value | Short name | Full name |
| --- | --- | --- |
| `0x00` | `req` | `PAYLOAD_TYPE_REQ` |
| `0x01` | `response` | `PAYLOAD_TYPE_RESPONSE` |
| `0x02` | `txt_msg` | `PAYLOAD_TYPE_TXT_MSG` |
| `0x03` | `ack` | `PAYLOAD_TYPE_ACK` |
| `0x04` | `advert` | `PAYLOAD_TYPE_ADVERT` |
| `0x05` | `grp_txt` | `PAYLOAD_TYPE_GRP_TXT` |
| `0x06` | `grp_data` | `PAYLOAD_TYPE_GRP_DATA` |
| `0x07` | `anon_req` | `PAYLOAD_TYPE_ANON_REQ` |
| `0x08` | `path` | `PAYLOAD_TYPE_PATH` |
| `0x09` | `trace` | `PAYLOAD_TYPE_TRACE` |
| `0x0A` | `multipart` | `PAYLOAD_TYPE_MULTIPART` |
| `0x0B` | `control` | `PAYLOAD_TYPE_CONTROL` |
| `0x0C` | `ota` | `PAYLOAD_TYPE_OTA` in this fork |
| `0x0D` | `13` | reserved |
| `0x0E` | `14` | reserved |
| `0x0F` | `raw_custom` | `PAYLOAD_TYPE_RAW_CUSTOM` |
The numeric values `0` through `15`, the full `PAYLOAD_TYPE_*` names, and
`any` are also accepted. Upstream currently reserves `0x0C`; this fork assigns
it to LoRa OTA.
## Moderate group text by channel and username
Use `flood.moderation` for flood `GRP_TXT` messages. The repeater validates and
decrypts the selected channel, extracts the display name before the first `:`,
then applies the rule:
```text
set flood.moderation <channel> <sender> <action> [action...]
set flood.moderation.<slot> <channel> <sender> <action> [action...]
get flood.moderation
get flood.moderation.<slot>
del flood.moderation.<slot>
del flood.moderation all
```
Channels can be specified as:
- `public` for the built-in Public channel
- `#name` for a well-known hashtag channel
- a 128-bit or 256-bit channel key in hexadecimal
The key is stored locally so packets can be authenticated and decrypted. It is
not included in `get flood.moderation` output.
Available actions are:
- `drop` — do not retransmit any matching message
- `rate=X/min` — retransmit at most `X` messages per local 60-second window
- `hops=N` — do not retransmit when the received path count is `N` or higher
- `path=H1[,H2,H3]` — require the first one to three path hashes to match
- `path=*` — match every path; this is the default
At least one of `drop`, `rate=X/min`, or `hops=N` is required. Rate and hop
limits can be combined. `rate=0/min` is equivalent to `drop`.
### Per-user, per-channel rate limits
Rate limits require an exact username; `*` is not accepted for a rate rule.
Username comparison is ASCII case-insensitive, and names containing spaces must
be quoted. A rule's counter is independent from rules for the same name on
other channels, so this directly supports “X messages per minute from user X
on channel Y.” Counters are local to this repeater and reset on reboot.
```text
# At most five Public-channel messages per minute from this display name.
set flood.moderation public "Noisy User" rate=5/min
# A separate limit for the same name on #local.
set flood.moderation #local "Noisy User" rate=10/min
# Combine a rate limit with a maximum forwarding distance.
set flood.moderation public alice rate=4/min hops=5
```
### Match the start of a path
Path matching accepts one-, two-, or three-byte hashes. Every hash in one rule
must use the same width, and matching always starts at the beginning of the
received path:
```text
set flood.moderation #local bot drop path=A1B2C3,D4E5F6
set flood.moderation public alice rate=3/min path=71
```
A path-qualified rule cannot match a zero-hop packet and does not match until
the packet contains all path entries listed by the rule.
## How the forwarding controls combine
A flood packet is retransmitted only if it passes every applicable control. In
other words, the controls combine as deny rules:
1. `repeat`, `flood.max*`, and the channel-data gate are checked.
2. `flood.filter` checks payload type and hop range.
3. `flood.channel.block` checks keyed channels.
4. Region and loop-detection rules are checked.
5. `flood.moderation` checks decrypted group text, username, rate, hops, and path.
The first denial is enough to prevent retransmission. A packet that is denied
can still appear in local logs or MQTT output. Moderation runs last because its
rate counters are charged only for packets that pass every other forwarding
control and will actually be retransmitted.
## Delegate filter management
ACL permission `5` is the filter-manager role:
```text
setperm <companion-public-key-hex> 5
```
A filter manager can read non-secret operational status and manage `repeat`,
`loop.detect`, `flood.max*`, `flood.channel.data*`, `flood.channel.block*`,
`flood.filter*`, and `flood.moderation*`. Delegated `get` access uses an
explicit allowlist: it cannot retrieve guest, WiFi, MQTT, bridge, or other
credentials, and it cannot change regions, ACL entries, radio settings, or
unrelated administrator settings.
## Security limitations
Public and hashtag channels use shared, well-known keys. A valid channel MAC
proves that the sender knew the channel key; it does not identify a person.
The `<sender>` value is an unverified display name and can be spoofed. Path
hashes are truncated routing hints and can collide or be manipulated; they are
not authenticated user identities.
Use username and path rules as traffic moderation, not as an authorization
boundary. For a strict network boundary, combine these tools with region ACLs,
private transport/channel keys, and controlled device access.
## Remove the custom rules
To return the two new tables to their default empty state:
```text
del flood.filter all
del flood.moderation all
get flood.filter
get flood.moderation
```
This does not change the older `flood.max*`, channel-block, loop-detection, or
region settings; inspect or reset those separately when troubleshooting.
+8
View File
@@ -2,6 +2,8 @@
This file covers only CLI settings and helper commands added by the Halo or
Keymind branches. Use `docs/cli_commands.md` for the general MeshCore CLI.
See [Repeater Flood Filtering and Moderation](flood_filtering.md) for a focused
filter setup and troubleshooting guide.
## Quick Start
@@ -81,6 +83,12 @@ set flood.retry.ignore none
| `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops <all|1-7>` | `set flood.channel.data.hops 7` |
| `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=<all|1-7|default>` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] <key|#channel> [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` |
| `flood.channel.block.hops` | Limits keyed channel-block matches to short flood paths. `all` blocks matching packets at any hop count; `1`-`7` repeats packets at that hop count or lower and blocks longer matches. This does not restrict unkeyed `GRP_DATA`; use `flood.channel.data.hops` for that. | `get flood.channel.block.hops`, `set flood.channel.block.hops <all|1-7>` | `set flood.channel.block.hops 3` |
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and received hop count/range. Direct routing and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> <N|N+|N-M|all>`, `del flood.filter.<n>|all` | `set flood.filter grp_data 4+` |
| `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.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` |
| `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` |
+124 -29
View File
@@ -14,8 +14,10 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
return delay_ms > 30000UL ? 30000UL : delay_ms;
}
static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL;
#ifndef RXPS_FIXED_ENABLED
#define RXPS_FIXED_ENABLED 1
#define RXPS_FIXED_ENABLED 0
#endif
#ifndef RXPS_FIXED_LEVEL
#define RXPS_FIXED_LEVEL 2
@@ -350,11 +352,11 @@ static bool calcFixedRxPowerSaving(uint8_t sf, float bw, uint32_t* rx_us, uint32
return true;
}
static bool applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
static mesh::RadioParamApplyResult applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
uint32_t rx_us, sleep_us;
if (!calcFixedRxPowerSaving(sf, bw, &rx_us, &sleep_us)) {
MESH_DEBUG_PRINTLN("RX Power Saving fixed profile invalid");
return false;
return mesh::RadioParamApplyResult::FAILED;
}
uint32_t timings[2] = {rx_us, sleep_us};
@@ -363,14 +365,17 @@ static bool applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr)
// radio does not implement RX duty cycling. This matches the former
// setParams()+setRxPowerSaving() behavior while retaining one atomic
// transition on radios that do support it.
bool ok = radio_driver.setParams(freq, bw, sf, cr, supports_rxps ? timings : NULL);
mesh::RadioParamApplyResult result =
radio_driver.trySetParams(freq, bw, sf, cr, supports_rxps ? timings : NULL);
MESH_DEBUG_PRINTLN("RX Power Saving fixed level %d p%d: %s (%lu/%lu us)",
RXPS_FIXED_LEVEL,
RXPS_FIXED_PREAMBLE,
ok && supports_rxps ? "Enabled" : (ok ? "Unsupported" : "Apply failed"),
result == mesh::RadioParamApplyResult::APPLIED
? (supports_rxps ? "Enabled" : "Unsupported")
: (result == mesh::RadioParamApplyResult::BUSY ? "Busy" : "Apply failed"),
(unsigned long)rx_us,
(unsigned long)sleep_us);
return ok;
return result;
}
#endif
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
@@ -1115,6 +1120,13 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
saved_radio_apply_pending = false;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
command_radio_apply_pending = false;
command_radio_freq = 0.0f;
command_radio_bw = 0.0f;
command_radio_sf = 0;
command_radio_cr = 0;
command_radio_repeat = 0;
command_radio_apply_deadline = 0;
offline_queue_len = 0;
app_target_ver = 0;
clearPendingReqs();
@@ -1278,14 +1290,80 @@ void MyMesh::begin(bool has_display) {
#endif
}
bool MyMesh::applySavedRadioParams() {
mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
#if RXPS_FIXED_ENABLED
return applyFixedRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
return applyFixedRadioParams(freq, bw, sf, cr);
#else
return radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
return radio_driver.trySetParams(freq, bw, sf, cr);
#endif
}
bool MyMesh::applySavedRadioParams() {
return tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr)
== mesh::RadioParamApplyResult::APPLIED;
}
void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat) {
_prefs.sf = sf;
_prefs.cr = cr;
_prefs.freq = freq;
_prefs.bw = bw;
_prefs.client_repeat = repeat;
savePrefs();
saved_radio_apply_pending = false;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d",
(uint32_t)(freq * 1000.0f), (uint32_t)(bw * 1000.0f),
(uint32_t)sf, (uint32_t)cr);
writeOKFrame();
}
void MyMesh::cancelPendingRadioParamApply() {
if (!command_radio_apply_pending) return;
command_radio_apply_pending = false;
command_radio_apply_deadline = 0;
// The requested tuple was not committed. Reassert the persisted tuple in
// case a failed hardware apply only restored part of the old configuration.
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
}
void MyMesh::servicePendingRadioParamApply() {
if (!command_radio_apply_pending) return;
if (!_serial || !_serial->isConnected()) {
cancelPendingRadioParamApply();
return;
}
// Leave enough queue headroom for the eventual command response.
if (_serial->isWriteBusy()) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr);
if (result == mesh::RadioParamApplyResult::APPLIED) {
float freq = command_radio_freq;
float bw = command_radio_bw;
uint8_t sf = command_radio_sf;
uint8_t cr = command_radio_cr;
uint8_t repeat = command_radio_repeat;
command_radio_apply_pending = false;
command_radio_apply_deadline = 0;
finishRadioParamApply(freq, bw, sf, cr, repeat);
} else if (result == mesh::RadioParamApplyResult::FAILED) {
cancelPendingRadioParamApply();
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (command_radio_apply_deadline == _ms->getMillis()
|| millisHasNowPassed(command_radio_apply_deadline)) {
cancelPendingRadioParamApply();
writeErrFrame(ERR_CODE_BAD_STATE);
}
}
const char *MyMesh::getNodeName() {
return _prefs.node_name;
}
@@ -1408,6 +1486,7 @@ void MyMesh::handleCmdFrame(size_t len) {
MESH_DEBUG_PRINTLN("App %s connected", app_name);
_iter_started = false; // stop any left-over ContactsIterator
cancelPendingRadioParamApply();
int i = 0;
out_frame[i++] = RESP_CODE_SELF_INFO;
out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-)
@@ -1809,29 +1888,34 @@ void MyMesh::handleCmdFrame(size_t len) {
bw <= 500000) {
float new_freq = (float)freq / 1000.0;
float new_bw = (float)bw / 1000.0;
#if RXPS_FIXED_ENABLED
bool applied = applyFixedRadioParams(new_freq, new_bw, sf, cr);
#else
bool applied = radio_driver.setParams(new_freq, new_bw, sf, cr);
#endif
if (!applied) {
// Persisted settings remain authoritative after a rejected change.
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
if (command_radio_apply_pending) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
_prefs.sf = sf;
_prefs.cr = cr;
_prefs.freq = new_freq;
_prefs.bw = new_bw;
_prefs.client_repeat = repeat;
savePrefs();
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);
mesh::RadioParamApplyResult result = tryApplyRadioParams(new_freq, new_bw, sf, cr);
if (result == mesh::RadioParamApplyResult::BUSY) {
command_radio_apply_pending = true;
command_radio_freq = new_freq;
command_radio_bw = new_bw;
command_radio_sf = sf;
command_radio_cr = cr;
command_radio_repeat = repeat;
command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS);
MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy");
return;
}
if (result == mesh::RadioParamApplyResult::FAILED) {
// Persisted settings remain authoritative after a rejected change or
// a hardware apply that had to be rolled back.
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
writeOKFrame();
finishRadioParamApply(new_freq, new_bw, sf, cr, repeat);
} else {
MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);
@@ -1938,6 +2022,7 @@ void MyMesh::handleCmdFrame(size_t len) {
self_id = identity;
writeOKFrame();
// re-load contacts, to invalidate ecdh shared_secrets
_iter_started = false;
resetContacts();
_store->loadContacts(this);
updateGpsTelemetryPolicy();
@@ -2668,6 +2753,12 @@ void MyMesh::checkCLIRescueCmd() {
void MyMesh::checkSerialInterface() {
size_t len = _serial->checkRecvFrame(cmd_frame);
if (!_serial->isConnected()) {
_iter_started = false;
cancelPendingRadioParamApply();
return;
}
if (len > 0) {
handleCmdFrame(len);
} else if (_iter_started // check if our ContactsIterator is 'running'
@@ -2695,7 +2786,7 @@ void MyMesh::checkSerialInterface() {
void MyMesh::loop() {
BaseChatMesh::loop();
if (saved_radio_apply_pending && !hasOutbound()
if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) {
// A power-saving wake can enter begin() with a complete packet already
// waiting. Preserve that packet, then apply the persisted radio settings
@@ -2725,6 +2816,7 @@ void MyMesh::loop() {
} else {
checkSerialInterface();
}
servicePendingRadioParamApply();
// is there are pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
@@ -2755,7 +2847,10 @@ bool MyMesh::advert() {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()) return true;
return hasQueuedWorkDue() || hasRetryWorkDue()
return (_serial != NULL && _serial->hasPendingIO())
|| (_iter_started && _serial != NULL && _serial->isConnected())
|| command_radio_apply_pending
|| hasQueuedWorkDue() || hasRetryWorkDue()
|| (saved_radio_apply_pending
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at)))
|| (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry))
+11
View File
@@ -222,7 +222,11 @@ private:
bool isValidClientRepeatFreq(uint32_t f) const;
bool hasLocationTelemetryRecipient();
void updateGpsTelemetryPolicy();
mesh::RadioParamApplyResult tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr);
bool applySavedRadioParams();
void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat);
void cancelPendingRadioParamApply();
void servicePendingRadioParamApply();
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
@@ -253,6 +257,13 @@ private:
bool saved_radio_apply_pending;
unsigned long radio_apply_retry_at;
uint8_t radio_apply_failures;
bool command_radio_apply_pending;
float command_radio_freq;
float command_radio_bw;
uint8_t command_radio_sf;
uint8_t command_radio_cr;
uint8_t command_radio_repeat;
unsigned long command_radio_apply_deadline;
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
uint8_t app_target_ver;
File diff suppressed because it is too large. Load diff
+119
View File
@@ -98,6 +98,9 @@ struct NeighbourInfo {
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "6 Jun 2026"
#endif
#ifndef FIRMWARE_BUILD_EPOCH
#define FIRMWARE_BUILD_EPOCH 0UL
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.17.0"
@@ -113,6 +116,40 @@ struct NeighbourInfo {
#define MAX_SCHEDULED_RADIO_SETTINGS (MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE * 2)
#ifndef FLOOD_PACKET_FILTER_SLOTS
#define FLOOD_PACKET_FILTER_SLOTS 16
#endif
#define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
#ifndef FLOOD_GROUP_MODERATION_SLOTS
#define FLOOD_GROUP_MODERATION_SLOTS 16
#endif
#define FLOOD_GROUP_MODERATION_NAME_LEN 32
#define FLOOD_GROUP_MODERATION_PATH_HOPS_MAX 3
#define FLOOD_GROUP_MODERATION_PATH_BYTES_MAX (FLOOD_GROUP_MODERATION_PATH_HOPS_MAX * 3)
#define FLOOD_GROUP_MODERATION_HOPS_ALL 0xFF
#define FLOOD_GROUP_MODERATION_RATE_UNLIMITED 0xFFFF
#ifndef CLOCK_SYNC_SAMPLE_SLOTS
#define CLOCK_SYNC_SAMPLE_SLOTS 16
#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_SAMPLE_MAX_AGE_MILLIS (2UL * 60UL * 60UL * 1000UL)
#define CLOCK_SYNC_CONSENSUS_WINDOW_SECONDS 600UL
#define CLOCK_SYNC_DRIFT_MIN_SECONDS 30UL
#define CLOCK_SYNC_DRIFT_MAX_SECONDS 86400UL
#define CLOCK_SYNC_DRIFT_DEFAULT_SECONDS 3600UL
#define CLOCK_SYNC_MESH_SUPPRESS_NONE 0
#define CLOCK_SYNC_MESH_SUPPRESS_CLI 1
#define CLOCK_SYNC_MESH_SUPPRESS_GPS 2
#define CLOCK_SYNC_MESH_SUPPRESS_INTERNET 3
#define RECENT_REPEATER_MAX_AGE_MILLIS (24UL * 60UL * 60UL * 1000UL)
#define RECENT_REPEATER_SWEEP_INTERVAL_MILLIS (3UL * 60UL * 60UL * 1000UL)
#define RADIO_APPLY_RETRY_INTERVAL_MILLIS 1000UL
@@ -176,9 +213,58 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
uint8_t secret[PUB_KEY_SIZE];
char name[FLOOD_CHANNEL_BLOCK_NAME_LEN];
};
struct FloodPacketFilterEntry {
bool active;
uint8_t payload_type;
uint8_t min_hops;
uint8_t max_hops;
};
struct FloodGroupModerationEntry {
bool active;
uint8_t key_len;
uint8_t hash_prefix[FLOOD_CHANNEL_BLOCK_PREFIX_LEN];
uint8_t secret[PUB_KEY_SIZE];
char channel_name[FLOOD_GROUP_MODERATION_NAME_LEN];
char sender[FLOOD_GROUP_MODERATION_NAME_LEN];
uint8_t path_hash_size;
uint8_t path_hops;
uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX];
uint8_t max_hops;
uint16_t rate_per_minute;
uint32_t rate_window_started;
uint16_t rate_window_count;
bool rate_window_active;
};
struct ClockSyncSample {
bool active;
uint8_t source_kind;
uint8_t source_id[4];
uint8_t path_id[CLOCK_SYNC_PATH_ID_SIZE];
uint32_t epoch;
uint32_t received_millis;
};
mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS];
FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1];
FloodChannelBlockEntry flood_channel_blocks[FLOOD_CHANNEL_BLOCK_SLOTS];
FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS];
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS];
bool clock_sync_mesh_enabled;
bool clock_sync_internet_enabled;
bool clock_sync_complete;
bool clock_sync_internet_pending;
uint8_t clock_sync_mesh_suppressed_by;
uint8_t clock_sync_last_result;
uint8_t clock_sync_last_source;
uint8_t clock_sync_last_sample_count;
uint8_t clock_sync_last_fresh_count;
uint8_t clock_sync_last_required_count;
uint8_t clock_sync_required_samples;
uint32_t clock_sync_drift_seconds;
uint32_t clock_sync_last_estimate;
uint32_t clock_sync_last_abs_drift;
uint32_t clock_sync_internet_requested_millis;
uint64_t clock_sync_next_attempt_uptime;
uint32_t pending_discover_tag;
unsigned long pending_discover_until;
bool region_load_active;
@@ -294,6 +380,37 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
bool floodChannelDataHopApplies(const mesh::Packet* packet) const;
bool floodChannelBlockMatches(const FloodChannelBlockEntry& entry, const mesh::Packet* packet) const;
bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const;
void loadFloodPacketFilters();
bool saveFloodPacketFilters();
bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const;
void formatFloodPacketFilters(const char* args, char* reply) const;
void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const;
void setFloodPacketFilter(const char* args, char* reply);
void deleteFloodPacketFilter(const char* args, char* reply);
void loadFloodGroupModeration();
bool saveFloodGroupModeration();
bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet);
void formatFloodGroupModeration(const char* args, char* reply) const;
void formatFloodGroupModerationDetail(int index, char* reply, size_t reply_len) const;
void setFloodGroupModeration(const char* args, char* reply);
void deleteFloodGroupModeration(const char* args, char* reply);
bool decodeFloodGroupPlainText(const mesh::Packet* packet, const uint8_t* secret, uint8_t key_len,
uint32_t& timestamp, char* sender, size_t sender_len) const;
void loadClockSyncPrefs();
bool saveClockSyncPrefs();
void resetClockSyncAttempt();
void suppressMeshClockSyncForBoot(uint8_t source);
void checkGpsClockSyncOverride();
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);
void recordAcceptedFloodClockSample(const mesh::Packet* packet);
void recordPublicChannelClockSample(const mesh::Packet* packet);
bool estimateMeshClock(uint32_t& estimate, uint8_t& fresh_count,
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;
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;
@@ -410,6 +527,8 @@ public:
_cli.savePrefs(_fs);
}
void onManualClockSet() override;
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
// CommonCLICallbacks
+6 -1
View File
@@ -10,6 +10,11 @@ class RateLimiter {
public:
RateLimiter(uint16_t maximum, uint32_t secs): _maximum(maximum), _secs(secs), _start_timestamp(0), _count(0) { }
void reset() {
_start_timestamp = 0;
_count = 0;
}
bool allow(uint32_t now) {
if (now < _start_timestamp + _secs) {
_count++;
@@ -20,4 +25,4 @@ public:
}
return true;
}
};
};
+24
View File
@@ -8,6 +8,12 @@
namespace mesh {
enum class RadioParamApplyResult : uint8_t {
APPLIED,
BUSY,
FAILED
};
/**
* \brief Abstraction of local/volatile clock with Millisecond granularity.
*/
@@ -89,6 +95,24 @@ public:
virtual bool supportsRxPowerSaving() const { return false; }
virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { return !enabled; }
/**
* \brief Attempts to change the radio modulation parameters without waiting.
*
* BUSY means the request is valid but the radio is temporarily unable to
* leave RX/TX. Callers may retry it from their main loop. FAILED means the
* radio attempted or rejected the change and retrying the same tuple is not
* expected to help.
*/
virtual RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings = nullptr) {
(void)freq;
(void)bw;
(void)sf;
(void)cr;
(void)rx_ps_timings;
return RadioParamApplyResult::FAILED;
}
/**
* \returns true if the radio is currently mid-receive of a packet.
*/
+7
View File
@@ -140,6 +140,13 @@ public:
}
return last_unique = t;
}
/** Reset the monotonic timestamp helper after an intentional RTC correction.
* This is needed when a caller explicitly permits moving the wall clock
* backward and wants subsequent generated timestamps to use the new clock. */
void resetUniqueTime(uint32_t time) {
last_unique = time > 0 ? time - 1 : 0;
}
};
}
+4
View File
@@ -17,6 +17,10 @@ public:
virtual bool isReadBusy() const = 0;
virtual bool isWriteBusy() const = 0;
// True while application-level transport work still needs loop service.
// Backends whose busy methods are throttling/high-water signals should
// override this with their actual queue state.
virtual bool hasPendingIO() const { return isReadBusy() || isWriteBusy(); }
virtual size_t writeFrame(const uint8_t src[], size_t len) = 0;
virtual size_t checkRecvFrame(uint8_t dest[]) = 0;
};
+1 -2
View File
@@ -110,8 +110,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
ClientInfo* oldest = NULL;
for (int i = 0; i < num_clients; i++) {
if (id.matches(clients[i].id)) return &clients[i]; // already known
if ( (!clients[i].isAdmin() && !clients[i].isRegionMgr())
&& clients[i].last_activity < min_time) {
if (!clients[i].isProtectedManager() && clients[i].last_activity < min_time) {
oldest = &clients[i];
min_time = oldest->last_activity;
}
+3
View File
@@ -10,6 +10,7 @@
#define PERM_ACL_READ_WRITE 2
#define PERM_ACL_ADMIN 3
#define PERM_ACL_REGION_MGR 4
#define PERM_ACL_FILTER_MGR 5
#define OUT_PATH_FORCE_FLOOD 0xFE
#define OUT_PATH_UNKNOWN 0xFF
@@ -36,6 +37,8 @@ struct ClientInfo {
bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; }
bool isRegionMgr() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_REGION_MGR; }
bool isFilterMgr() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_FILTER_MGR; }
bool isProtectedManager() const { return isAdmin() || isRegionMgr() || isFilterMgr(); }
};
#ifndef MAX_CLIENTS
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <stdint.h>
namespace mesh {
struct ClockSyncConsensusResult {
bool consensus;
uint8_t fresh_count;
uint8_t agreeing_count;
uint8_t required_count;
uint32_t estimate;
};
// Evaluate an in-place set of current-time estimates. A configured quorum is
// always required, and when more samples are present the agreeing cluster must
// also be a strict majority. This makes an 8-vs-8 split fail instead of letting
// the upper median choose one side arbitrarily.
inline ClockSyncConsensusResult evaluateClockSyncConsensus(uint32_t* values,
uint8_t count,
uint8_t configured_required,
uint32_t window_seconds) {
ClockSyncConsensusResult result = {false, count, 0, configured_required, 0};
if (values == nullptr || count < configured_required) return result;
uint8_t strict_majority = (uint8_t)(count / 2U + 1U);
if (strict_majority > result.required_count) result.required_count = strict_majority;
for (uint8_t i = 1; i < count; i++) {
uint32_t value = values[i];
uint8_t j = i;
while (j > 0 && values[j - 1] > value) {
values[j] = values[j - 1];
j--;
}
values[j] = value;
}
result.estimate = values[count / 2U];
for (uint8_t i = 0; i < count; i++) {
uint32_t difference = values[i] > result.estimate
? values[i] - result.estimate : result.estimate - values[i];
if (difference <= window_seconds) result.agreeing_count++;
}
result.consensus = result.agreeing_count >= result.required_count;
return result;
}
} // namespace mesh
+2
View File
@@ -1780,6 +1780,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
uint32_t curr = getRTCClock()->getCurrentTime();
if (sender_timestamp > curr) {
getRTCClock()->setCurrentTime(sender_timestamp + 1);
_callbacks->onManualClockSet();
uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now);
sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
@@ -1825,6 +1826,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
uint32_t curr = getRTCClock()->getCurrentTime();
if (secs > curr) {
getRTCClock()->setCurrentTime(secs);
_callbacks->onManualClockSet();
uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now);
sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
+4
View File
@@ -351,6 +351,10 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
virtual void savePrefs() = 0;
// Called only after a CLI command successfully changes the RTC. Roles that
// derive time from untrusted radio traffic can use this to prefer the manual
// value for the remainder of the boot.
virtual void onManualClockSet() { }
virtual const char* getFirmwareVer() = 0;
virtual const char* getBuildDate() = 0;
virtual const char* getRole() = 0;
+3 -2
View File
@@ -297,8 +297,9 @@ void NRF52Board::sleep(uint32_t secs) {
sd_softdevice_is_enabled(&sd_enabled);
if (sd_enabled) {
// first call processes pending softdevice events, second call sleeps.
sd_app_evt_wait();
// A single call is required here. If an interrupt arrived since the last
// wait, SoftDevice returns immediately so the main loop can service the
// flag or BLE queue before sleeping again.
sd_app_evt_wait();
} else {
// softdevice is disabled, use raw WFE
+67
View File
@@ -413,6 +413,8 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs,
_ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
_ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false),
_ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0),
_ntp_estimate_requested(false), _ntp_estimate_done(false),
_ntp_estimate_ok(false), _ntp_estimate_epoch(0),
// Default to UTC; setRules() will be called from syncTimeWithNTP when a
// non-UTC timezone string is configured. Timezone has no default ctor,
// so we must pass rules here.
@@ -547,6 +549,12 @@ void MQTTBridge::begin() {
}
_cached_has_connected_slots = false;
_slots_setup_done = false;
// A prior bridge session may have ended while a delayed clock estimate was
// queued or being consumed. Never carry that handshake into this session.
_ntp_estimate_requested = false;
_ntp_estimate_done = false;
_ntp_estimate_ok = false;
_ntp_estimate_epoch = 0;
// PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM
#ifdef BOARD_HAS_PSRAM
@@ -838,6 +846,10 @@ void MQTTBridge::end() {
_initialized = false;
_slots_setup_done = false; // Reset so deferred setup runs again on next begin()
_ntp_estimate_requested = false;
_ntp_estimate_done = false;
_ntp_estimate_ok = false;
_ntp_estimate_epoch = 0;
MQTT_DEBUG_PRINTLN("MQTT Bridge stopped");
}
@@ -990,6 +1002,17 @@ void MQTTBridge::mqttTaskLoop() {
_ntp_diag_done = true; // set last so the waiter sees populated results
}
// Delayed clock bootstrap uses a non-blocking request from the radio task.
// Query only; the requester applies its configured drift threshold before
// deciding whether to move the RTC forward or backward.
if (_ntp_estimate_requested) {
runNtpEstimateProbe();
_ntp_estimate_done = true;
// Keep requested asserted for the entire probe so another caller cannot
// queue work that shares and overwrites the result fields above.
_ntp_estimate_requested = false;
}
// Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps.
// This avoids wasted TLS handshakes that get rejected due to bad token times.
if (_ntp_synced && !_slots_setup_done) {
@@ -2188,6 +2211,11 @@ void MQTTBridge::loop() {
_ntp_sync_pending = false;
syncTimeWithNTP();
}
if (_ntp_estimate_requested) {
runNtpEstimateProbe();
_ntp_estimate_done = true;
_ntp_estimate_requested = false;
}
// Deferred slot setup after NTP sync (non-ESP32 path)
if (_ntp_synced && !_slots_setup_done) {
@@ -3163,6 +3191,45 @@ void MQTTBridge::runNtpDiagProbe() {
r.epoch = ok ? (uint32_t)_ntp_client.getEpochTime() : 0;
}
_ntp_diag_count = count;
_ntp_client.end();
}
void MQTTBridge::runNtpEstimateProbe() {
const char* servers[kMaxNtpServers];
int count = 0;
fillNtpServerList(_obs, servers, count);
_ntp_estimate_ok = false;
_ntp_estimate_epoch = 0;
_ntp_client.begin();
for (int i = 0; i < count && !_ntp_estimate_ok; i++) {
_ntp_client.setPoolServerName(servers[i]);
if (!_ntp_client.forceUpdate()) continue;
uint32_t epoch = (uint32_t)_ntp_client.getEpochTime();
if (epoch >= 1704067200UL && epoch <= 4102444799UL) {
_ntp_estimate_epoch = epoch;
_ntp_estimate_ok = true;
}
}
_ntp_client.end();
}
bool MQTTBridge::requestNtpTimeEstimate() {
if (!isRunning() || WiFi.status() != WL_CONNECTED || _ntp_estimate_requested) return false;
_ntp_estimate_done = false;
_ntp_estimate_ok = false;
_ntp_estimate_epoch = 0;
_ntp_estimate_requested = true;
return true;
}
bool MQTTBridge::takeNtpTimeEstimate(uint32_t& epoch, bool& finished) {
finished = _ntp_estimate_done;
if (!finished) return false;
bool success = _ntp_estimate_ok;
epoch = success ? _ntp_estimate_epoch : 0;
_ntp_estimate_done = false; // consume the result
return success;
}
bool MQTTBridge::ntpDiag(char* reply, size_t reply_size, bool verbose) {
+14 -1
View File
@@ -199,7 +199,7 @@ private:
WiFiUDP _ntp_udp;
NTPClient _ntp_client;
unsigned long _last_ntp_sync;
bool _ntp_synced;
volatile bool _ntp_synced;
bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler
bool _slots_setup_done; // Deferred: slots set up after NTP sync
// WiFi.onEvent() handler registered once and never removed by end(); the bridge
@@ -236,6 +236,12 @@ private:
};
NtpDiagResult _ntp_diag_results[kMaxNtpServers];
int _ntp_diag_count;
// Non-blocking, read-only NTP estimate used by the delayed clock-bootstrap
// policy. The MQTT task performs network I/O; the radio/main task only polls.
volatile bool _ntp_estimate_requested;
volatile bool _ntp_estimate_done;
volatile bool _ntp_estimate_ok;
volatile uint32_t _ntp_estimate_epoch;
// Timezone handling.
// _timezone_storage is inline class storage (zero heap) that is reconfigured
@@ -380,6 +386,7 @@ private:
bool isAnySlotConnected();
void refreshNTP(); // Lightweight periodic NTP refresh (non-blocking)
void runNtpDiagProbe(); // Probe every server for connectivity; never sets the clock. Core 0 only.
void runNtpEstimateProbe(); // Query the first usable server without changing any clock. Core 0 only.
// Populates dst_out/std_out with TimeChangeRules for the given IANA or
// abbreviation string. Returns false if the string is not recognized
// (callers should fall back to UTC). Zero-allocation.
@@ -488,6 +495,12 @@ public:
* summary in reply; verbose=false fills reply with a compact "<server> ok|fail" list
* (for LoRa). Returns false if the bridge is not running. */
bool ntpDiag(char* reply, size_t reply_size, bool verbose);
/** Queue a read-only NTP query on the MQTT task without blocking the radio loop. */
bool requestNtpTimeEstimate();
/** Poll/consume the queued estimate. finished=false means it is still pending. */
bool takeNtpTimeEstimate(uint32_t& epoch, bool& finished);
/** True after this bridge has successfully set the RTC from NTP this boot. */
bool hasNtpTime() const { return _ntp_synced; }
static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs);
/** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */
static bool isConfigValid(const MQTTPrefs* obs);
+10 -1
View File
@@ -11,9 +11,18 @@ public:
uint32_t getRngSeed();
mesh::RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings = nullptr) override {
(void)freq;
(void)bw;
(void)sf;
(void)cr;
(void)rx_ps_timings;
return mesh::RadioParamApplyResult::APPLIED;
}
bool setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings = nullptr) {
return true;
return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED;
}
void powerOff() { /* no-op */ }
+1
View File
@@ -78,6 +78,7 @@ public:
bool isReadBusy() const override;
bool isWriteBusy() const override;
bool hasPendingIO() const override { return recv_queue_len > 0 || send_queue_len > 0; }
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
};
+1
View File
@@ -54,6 +54,7 @@ public:
bool isConnected() const override;
bool isReadBusy() const override;
bool isWriteBusy() const override;
bool hasPendingIO() const override { return recv_queue_len > 0 || send_queue_len > 0; }
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
+4
View File
@@ -408,3 +408,7 @@ bool SerialBLEInterface::isReadBusy() const {
bool SerialBLEInterface::isWriteBusy() const {
return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3);
}
bool SerialBLEInterface::hasPendingIO() const {
return recv_queue_len > 0 || send_queue_len > 0;
}
+1
View File
@@ -68,6 +68,7 @@ public:
bool isConnected() const override;
bool isReadBusy() const override;
bool isWriteBusy() const override;
bool hasPendingIO() const override;
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
};
+12 -5
View File
@@ -92,12 +92,14 @@ void RadioLibWrapper::endReconfigure(bool resume_rx) {
if (resume_rx) startRecv();
}
bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings) {
if (rx_ps_timings != NULL && !supportsRxPowerSaving()) return false;
mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings) {
if (rx_ps_timings != NULL && !supportsRxPowerSaving()) {
return mesh::RadioParamApplyResult::FAILED;
}
uint8_t resume_rx = beginReconfigure();
if (resume_rx > 1) return false;
if (resume_rx > 1) return mesh::RadioParamApplyResult::BUSY;
const bool had_previous_params = _params_valid;
const float previous_freq = _cur_freq;
@@ -137,7 +139,12 @@ bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr,
}
endReconfigure(resume_rx);
return success;
return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED;
}
bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings) {
return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED;
}
bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) {
+2
View File
@@ -128,6 +128,8 @@ public:
// When rx_ps_timings is supplied, update and re-arm the RX duty cycle in the
// same standby transition as the modulation change. Values are {rx, sleep}.
mesh::RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings = NULL) override;
bool setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings = NULL);
uint16_t getDefaultPreambleLength() const override { return preambleLengthForSF(_preamble_sf); }
+10
View File
@@ -6,10 +6,20 @@
class LocationProvider {
protected:
bool _time_sync_needed = true;
bool _time_sync_applied = false;
void markTimeSyncApplied() { _time_sync_applied = true; }
public:
virtual void syncTime() { _time_sync_needed = true; }
virtual bool waitingTimeSync() { return _time_sync_needed; }
// Edge-triggered notification for consumers that need to know a GPS time
// was actually written, rather than merely seeing a valid location fix.
bool consumeTimeSyncApplied() {
bool applied = _time_sync_applied;
_time_sync_applied = false;
return applied;
}
virtual long getLatitude() = 0;
virtual long getLongitude() = 0;
virtual long getAltitude() = 0;
@@ -150,6 +150,7 @@ public :
if (_time_sync_needed && time_valid > 2) {
if (_clock != NULL) {
_clock->setCurrentTime(getTimestamp());
markTimeSyncApplied();
_time_sync_needed = false;
_last_time_sync = millis();
}
@@ -2,6 +2,7 @@
#include <Ed25519.h>
#include <Mesh.h>
#include <helpers/ClockSyncUtils.h>
#include <helpers/StaticPoolPacketManager.h>
class TraceTestClock : public mesh::MillisecondClock {
@@ -66,6 +67,54 @@ public:
}
};
TEST(RTCClock, UniqueSequenceCanFollowAnIntentionalBackwardCorrection) {
TraceTestRTC rtc;
rtc.now = 100;
EXPECT_EQ(100U, rtc.getCurrentTimeUnique());
EXPECT_EQ(101U, rtc.getCurrentTimeUnique());
rtc.setCurrentTime(50);
rtc.resetUniqueTime(50);
EXPECT_EQ(50U, rtc.getCurrentTimeUnique());
}
TEST(ClockSyncConsensus, EightVsEightSplitDoesNotChooseTheUpperMedian) {
uint32_t values[16];
for (int i = 0; i < 8; i++) values[i] = 1000;
for (int i = 8; i < 16; i++) values[i] = 5000;
mesh::ClockSyncConsensusResult result =
mesh::evaluateClockSyncConsensus(values, 16, 9, 600);
EXPECT_FALSE(result.consensus);
EXPECT_EQ(16, result.fresh_count);
EXPECT_EQ(8, result.agreeing_count);
EXPECT_EQ(9, result.required_count);
}
TEST(ClockSyncConsensus, NineVsSevenStrictMajorityIsAccepted) {
uint32_t values[16];
for (int i = 0; i < 7; i++) values[i] = 1000;
for (int i = 7; i < 16; i++) values[i] = 5000;
mesh::ClockSyncConsensusResult result =
mesh::evaluateClockSyncConsensus(values, 16, 9, 600);
EXPECT_TRUE(result.consensus);
EXPECT_EQ(5000U, result.estimate);
EXPECT_EQ(9, result.agreeing_count);
EXPECT_EQ(9, result.required_count);
}
TEST(ClockSyncConsensus, ConfiguredEightStillCannotAcceptAnEightVsEightSplit) {
uint32_t values[16];
for (int i = 0; i < 8; i++) values[i] = 1000;
for (int i = 8; i < 16; i++) values[i] = 5000;
mesh::ClockSyncConsensusResult result =
mesh::evaluateClockSyncConsensus(values, 16, 8, 600);
EXPECT_FALSE(result.consensus);
EXPECT_EQ(9, result.required_count);
}
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);