Add repeater filtering, clock sync, and companion fixes
This commit is contained in:
1 parent
0d2eb2420c
commit
63776482e1
30 files changed
+2757
-65
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1735
-22
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user