mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-08-29 01:28:18 +00:00
Extend repeater flood control and logging reliability
This commit is contained in:
+219
-46
@@ -236,6 +236,18 @@ refresh is already in flight, the scope pass is queued behind it.
|
||||
|
||||
## Logging
|
||||
|
||||
Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every
|
||||
received radio frame. Serial output uses backpressure: if a connected host
|
||||
temporarily stops reading, packet processing waits for USB transmit space
|
||||
instead of silently omitting the record. A disconnected host cannot retain an
|
||||
unbounded capture, so logging deployments should keep the reader attached and
|
||||
draining the serial port.
|
||||
|
||||
Ordinary `-logging-` artifacts keep packet logging separate from LoRa OTA.
|
||||
Use the separately named `-ota-` artifact when LoRa OTA is required. A
|
||||
`-full-logging-ota-` artifact is intentionally the exception: FULL profiles
|
||||
include every supported feature.
|
||||
|
||||
### Begin capture of rx log to node storage
|
||||
**Usage:** `log start`
|
||||
|
||||
@@ -958,6 +970,15 @@ get clock.sync.status
|
||||
**Description:** When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of `int.thresh` - either, both, or none may be active.
|
||||
|
||||
The Cascade firmware profile defaults CAD to `on`; target-default builds continue to default it to `off`.
|
||||
The repeater applies the saved toggle to the radio during its periodic
|
||||
noise-floor service. With one runnable packet, a busy result uses the normal
|
||||
CAD retry delay and allows roughly four seconds of continuous busy results.
|
||||
As the runnable transmit queue grows, both delays are divided by its depth:
|
||||
retry spacing will not fall below 50 ms and the busy ceiling will not fall
|
||||
below 500 ms. Reaching the busy ceiling records a CAD-timeout error and
|
||||
attempts the next queued transmission rather than waiting indefinitely.
|
||||
Future-scheduled packets do not accelerate CAD. `get cad` includes the
|
||||
hardware busy-result count.
|
||||
|
||||
**Parameters:**
|
||||
- `on|off`: Enable or disable hardware CAD
|
||||
@@ -1188,12 +1209,12 @@ del flood.channel.block.2
|
||||
|
||||
---
|
||||
|
||||
#### Force a transport scope onto unscoped floods
|
||||
#### Force a transport scope onto floods
|
||||
**Usage:**
|
||||
- `get flood.channel.scope`
|
||||
- `get flood.channel.scope.<n>`
|
||||
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region>`
|
||||
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region>`
|
||||
- `set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [tx=slow]`
|
||||
- `set flood.channel.scope.<n> <channel|txt:*|login:*|other:*> <region> [tx=slow]`
|
||||
- `del flood.channel.scope.<n>`
|
||||
- `del flood.channel.scope all`
|
||||
|
||||
@@ -1214,6 +1235,11 @@ del flood.channel.block.2
|
||||
raw custom. TRACE is deliberately exempt from forced-scope wildcards.
|
||||
- `region`: Existing named region with a usable transport key. A unique region
|
||||
name prefix is accepted; wildcard region `*` is not a scope target.
|
||||
- `tx=slow`: Optional. Use an effective inbound `rxdelay` base of
|
||||
`max(2, configured rxdelay * 2)`, keep normal outbound queue priority, and
|
||||
schedule retransmission with the maximum supported `txdelay` factor of
|
||||
`2.0` after changing the scope. The default is fast; `tx=fast` may be
|
||||
supplied explicitly when replacing a slow row.
|
||||
|
||||
**Default:** No forced scopes.
|
||||
|
||||
@@ -1228,42 +1254,56 @@ slot. The three wildcard classes are independent and consume one slot each.
|
||||
form for row detail. Keyed rows are displayed by the first four bytes of their
|
||||
derived channel hash because channel secrets are never returned.
|
||||
|
||||
This acts on received, unscoped `ROUTE_TYPE_FLOOD` packets. For `GRP_TXT` and
|
||||
`GRP_DATA`, all exact channel-key rows are tried first and must validate the
|
||||
packet MAC/decryption. A row whose target region is missing or unusable is
|
||||
skipped; later exact rows and then `txt:*` are tried. Exact keyed rows with a
|
||||
usable target therefore beat `txt:*` regardless of slot number. `login:*` and
|
||||
`other:*` select their non-overlapping outer-type families without decrypting
|
||||
the payload. Duplicate rows within the same class are permitted with numbered
|
||||
slots; the lowest usable slot wins.
|
||||
This acts on received `ROUTE_TYPE_FLOOD` and
|
||||
`ROUTE_TYPE_TRANSPORT_FLOOD` packets. An unscoped packet gains the configured
|
||||
scope; an already-scoped packet has its existing transport codes replaced. For
|
||||
`GRP_TXT` and `GRP_DATA`, all exact channel-key rows are tried first and must
|
||||
validate the packet MAC/decryption. A row whose target region is missing or
|
||||
unusable is skipped; later exact rows and then `txt:*` are tried. Exact keyed
|
||||
rows with a usable target therefore beat `txt:*` regardless of slot number.
|
||||
`login:*` and `other:*` select their non-overlapping outer-type families
|
||||
without decrypting the payload. Duplicate rows within the same class are
|
||||
permitted with numbered slots; the lowest usable slot wins.
|
||||
|
||||
Standard traceroute is direct-routed and is therefore outside this flood-only
|
||||
table. A custom flood-form `TRACE` is also left unchanged: no wildcard adds a
|
||||
scope, an existing transport code is preserved, and region/unknown-code gates
|
||||
do not block it.
|
||||
|
||||
On a match, the repeater changes the route to
|
||||
`ROUTE_TYPE_TRANSPORT_FLOOD`, computes transport code 0 from the selected
|
||||
region key and packet payload, and leaves transport code 1 as zero. This occurs
|
||||
before region enforcement, forwarding filters, and the seen-packet lookup.
|
||||
Already-scoped packets and direct routes are never rewritten. The rewritten
|
||||
packet is no longer subject to `flood.max.unscoped`, but remains subject to
|
||||
normal payload handling, `flood.max`, region allow/deny, `flood.filter`,
|
||||
On a match, the repeater sets the route to `ROUTE_TYPE_TRANSPORT_FLOOD`,
|
||||
computes transport code 0 from the selected region key and packet payload, and
|
||||
sets transport code 1 to zero. This occurs before region enforcement,
|
||||
forwarding filters, and the seen-packet lookup. For an already-scoped packet,
|
||||
the selected code replaces both incoming transport-code fields. Direct routes
|
||||
are never rewritten. A packet converted from unscoped is no longer subject to
|
||||
`flood.max.unscoped`; all rewritten packets remain subject to normal payload
|
||||
handling, `flood.max`, region allow/deny, `flood.filter`,
|
||||
`flood.channel.block`, loop detection, and moderation. Assigning a scope does
|
||||
not make a packet type forwardable if the core would otherwise reject it.
|
||||
not make a packet type forwardable if the core would otherwise reject it. By
|
||||
default, if the selected scope differs and the rewritten packet is accepted
|
||||
for forwarding, its initial retransmission uses zero `txdelay` and the highest
|
||||
outbound queue priority so the newly scoped copy can win at the next hop.
|
||||
Adding `tx=slow` uses an effective inbound `rxdelay` base of
|
||||
`max(2, configured rxdelay * 2)`, keeps the ordinary queue priority, and uses
|
||||
the maximum `txdelay` factor of `2.0` for the retransmission. As with ordinary
|
||||
`txdelay`, the actual transmit delay is randomized from zero through the
|
||||
resulting window; factor `2.0` gives a maximum of ten packet airtimes.
|
||||
Neither mode preempts an active radio transmission or bypasses CAD and
|
||||
airtime-budget limits. Selecting the scope already carried by the packet is a
|
||||
no-op and does not grant special transmit treatment.
|
||||
|
||||
If a row's target region has been removed or has no usable key, the repeater
|
||||
tries the next applicable row. For group packets this means later authenticated
|
||||
exact rows followed by `txt:*`; wildcard duplicates likewise fall through to
|
||||
the next usable slot. The packet remains unscoped only when no usable mapping
|
||||
exists.
|
||||
the next usable slot. When no usable mapping exists, the packet retains its
|
||||
original unscoped or scoped route.
|
||||
|
||||
LoRa OTA remains functional when `other:*` is configured. OTA packets are
|
||||
given that region's transport code, but the OTA handler still accepts and
|
||||
re-floods them during the temporary-radio window. The target region must allow
|
||||
flooding. The OTA core itself is dormant outside that window; no default flood
|
||||
filter row is needed for that behavior. Forced scope does not make OTA operate
|
||||
outside the window.
|
||||
given that region's transport code, replacing an existing code when necessary,
|
||||
but the OTA handler still accepts and re-floods them during the temporary-radio
|
||||
window. The target region must allow flooding. The OTA core itself is dormant
|
||||
outside that window; no default flood filter row is needed for that behavior.
|
||||
Forced scope does not make OTA operate outside the window.
|
||||
|
||||
**Capacity cost:** Each slot uses 36 bytes of runtime RAM and persistent
|
||||
storage, plus a 5-byte file header. The four-slot minimum uses 144 bytes RAM and
|
||||
@@ -1283,9 +1323,9 @@ same payload later arrives scoped, unscoped, or through a different region, it
|
||||
is still the same seen packet. `TRACE` is the exception only in that its
|
||||
encoded `path_len` byte is also hashed.
|
||||
|
||||
While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal receive-quality
|
||||
timing still selects the packet to process, but that winner receives a scope
|
||||
from the queued scoped copies with the same dedupe identity. If the copies
|
||||
While equivalent non-TRACE flood copies are waiting in `rxdelay`, the normal
|
||||
receive-quality timing still selects the packet to process, but that winner
|
||||
receives a scope from the queued scoped copies with the same dedupe identity. If the copies
|
||||
carry different locally allowed scopes, the scope from the shortest received
|
||||
path wins. Unknown and denied transport codes are not candidates and therefore
|
||||
cannot overwrite an unscoped winner. With equal path lengths, the deeper child
|
||||
@@ -1299,12 +1339,17 @@ scope and path for arbitration. It can only use copies still present in
|
||||
is excluded from scope arbitration entirely, so rxdelay never adds or replaces
|
||||
a trace transport code.
|
||||
|
||||
A packet that matches a fast `flood.channel.scope` or `flood.filter scope=`
|
||||
action and needs its scope changed bypasses the inbound `rxdelay` queue. A
|
||||
`tx=slow` row remains in that queue with twice the configured base, floored at
|
||||
`2.0`, and participates in normal queued-copy scope arbitration.
|
||||
|
||||
**Examples:**
|
||||
```text
|
||||
region put west
|
||||
region save
|
||||
set flood.channel.scope #local west
|
||||
set flood.channel.scope.2 txt:* west
|
||||
set flood.channel.scope.2 txt:* west tx=slow
|
||||
set flood.channel.scope.3 login:* west
|
||||
set flood.channel.scope.4 other:* west
|
||||
get flood.channel.scope
|
||||
@@ -1314,7 +1359,54 @@ del flood.channel.scope.2
|
||||
|
||||
---
|
||||
|
||||
#### Filter flood packets by payload type and hop
|
||||
#### Require valid incoming scopes only on selected channels
|
||||
|
||||
**Usage:**
|
||||
- `get flood.channel.scope.require`
|
||||
- `get flood.channel.scope.require.<n>`
|
||||
- `set flood.channel.scope.require <public|#channel|128/256-bit-key>`
|
||||
- `set flood.channel.scope.require.<n> <public|#channel|128/256-bit-key>`
|
||||
- `del flood.channel.scope.require.<n>`
|
||||
- `del flood.channel.scope.require all`
|
||||
|
||||
**Default:** Empty; normal global region enforcement remains active.
|
||||
|
||||
Once this table contains a row, received flood `GRP_TXT` and `GRP_DATA`
|
||||
packets use selective region enforcement. A packet authenticating against a
|
||||
listed channel key must already carry a transport scope matching a locally
|
||||
flood-allowed region. Listed channels arriving unscoped, with an unknown code,
|
||||
or with a denied region are not retransmitted. This tests the original
|
||||
incoming scope before any `flood.channel.scope` or `flood.filter scope=`
|
||||
rewrite. Those rewrite actions are skipped for a rejected listed channel, so
|
||||
they cannot rescue it or grant special receive/transmit timing.
|
||||
|
||||
Other group channels bypass the region/unknown-code gate while the table is
|
||||
active. They remain subject to every other forwarding control, including
|
||||
`repeat`, `flood.max*`, packet filters, channel blocks, loop detection, payload
|
||||
validation, and moderation. Non-channel flood payload types retain normal
|
||||
global region enforcement.
|
||||
|
||||
Channel matching validates the packet MAC/decryption with the configured key;
|
||||
the visible one-byte channel hash is only a prefilter. Public hashtag channels
|
||||
use their derived public key. Without `.n`, setting an existing key updates it
|
||||
and a new key uses the first empty slot. Numbered `set` replaces that slot.
|
||||
`get ...<n>` reports a four-byte derived prefix and key size without exposing
|
||||
the key. The table uses the same build-dependent slot count as
|
||||
`flood.channel.scope`.
|
||||
|
||||
Remote ACL permission `4` can manage this table. Deleting its final row
|
||||
restores normal global region enforcement for group channels.
|
||||
|
||||
**Example:**
|
||||
```text
|
||||
set flood.channel.scope.require #bot
|
||||
get flood.channel.scope.require
|
||||
get flood.channel.scope.require.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Filter flood packets by payload type, hop count, and path
|
||||
|
||||
For setup guidance, interactions with the existing forwarding controls, and
|
||||
worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_filtering.md).
|
||||
@@ -1322,8 +1414,14 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_
|
||||
**Usage:**
|
||||
- `get flood.filter`
|
||||
- `get flood.filter.<n>`
|
||||
- `set flood.filter <type> [hops] [suspend=tempradio]`
|
||||
- `set flood.filter.<n> <type> [hops] [suspend=tempradio]`
|
||||
- `get flood.filter.blacklist`
|
||||
- `get flood.filter.blacklist.<n>`
|
||||
- `set flood.filter.blacklist <ID[,ID...]>`
|
||||
- `set flood.filter.blacklist.<n> <ID[,ID...]>`
|
||||
- `del flood.filter.blacklist`
|
||||
- `del flood.filter.blacklist.<n>`
|
||||
- `set flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`
|
||||
- `set flood.filter.<n> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`
|
||||
- `del flood.filter.<n>`
|
||||
- `del flood.filter all`
|
||||
|
||||
@@ -1340,6 +1438,27 @@ worked moderation examples, see [Repeater Flood Filtering and Moderation](flood_
|
||||
the saved range as `all`.
|
||||
- `suspend=tempradio`: Optional. Skip this row only while the temporary radio
|
||||
is actually active.
|
||||
- `scope=<name>`: Optional scope-setting action. The name is normalized with a
|
||||
leading `#` and its 128-bit transport key is derived directly from that
|
||||
hashtag. It does not need to exist in the region list. Public names up to 30
|
||||
characters are accepted; private `$` scopes are not.
|
||||
- `require=region`: Optional and valid only with `scope=`. Apply the scope
|
||||
rewrite only if the original incoming packet already passes this repeater's
|
||||
region gate. An incoming transport scope must resolve to a locally allowed
|
||||
region; an unscoped flood must be allowed by the wildcard region. The check
|
||||
occurs before any scope rewrite during this receive pass.
|
||||
- `tx=slow`: Optional and valid only with `scope=`. Use an effective inbound
|
||||
`rxdelay` base of `max(2, configured rxdelay * 2)`, keep normal outbound
|
||||
queue priority, and retransmit with the maximum supported `txdelay` factor
|
||||
of `2.0`. Scope rows default to fast; `tx=fast` explicitly restores that
|
||||
default when replacing a slow row.
|
||||
- `path=blacklist`: Optional unordered path condition. The persistent blacklist
|
||||
contains up to 255 unique 3-byte repeater IDs on ESP32 builds and 18 on other
|
||||
builds, each written as six hexadecimal digits. A packet with 3-byte path
|
||||
hashes matches after one exact ID hit. A packet with 2-byte path hashes
|
||||
matches after two path entries match the first two bytes of listed IDs.
|
||||
Packets with 1-byte path hashes never match this condition. Each received
|
||||
path entry is counted at most once.
|
||||
|
||||
The payload names follow the [MeshCore packet-format allocation](https://docs.meshcore.io/packet_format/):
|
||||
|
||||
@@ -1367,12 +1486,39 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me
|
||||
`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. While the
|
||||
temporary radio is active, only rows explicitly marked `suspend=tempradio` are
|
||||
skipped. `tempradio` is a radio state, not an OTA mode; normal payload types can
|
||||
also use the temporary channel. Other rows remain in force. A malformed
|
||||
persisted table fails open (no general rules are applied).
|
||||
**Behavior:** A row with `path=blacklist` must meet the path condition as well
|
||||
as its payload-type and hop-range conditions. Blacklist IDs can occur anywhere
|
||||
in the received path and their configured order is irrelevant. A matching row
|
||||
without `scope=` prevents retransmission by this
|
||||
repeater. A matching row with `scope=` instead sets or replaces the packet's
|
||||
transport scope and does not block it. The lowest-numbered matching scope row
|
||||
wins; matching drop rows remain independent and can still block the rewritten
|
||||
packet. Scope rewriting happens before region enforcement and is trusted even
|
||||
when its name is absent from the local region list. It does not bypass
|
||||
`repeat`, `flood.max`, other drop rows, channel blocking, loop detection, or
|
||||
moderation.
|
||||
|
||||
With `require=region`, a failed check makes that scope row ineligible. It leaves
|
||||
the packet unchanged and does not set the filter-scope trust bypass, so an
|
||||
unknown or denied incoming region is rejected normally unless another
|
||||
independent scope rule rewrites it. Later eligible filter scope rows may still
|
||||
match.
|
||||
|
||||
By default, when a scope row will change the packet's transport codes, the
|
||||
packet bypasses inbound `rxdelay`; its retransmission then uses zero `txdelay`
|
||||
and the highest outbound queue priority. With `tx=slow`, the rewrite instead
|
||||
uses an effective inbound `rxdelay` base of
|
||||
`max(2, configured rxdelay * 2)`, normal queue priority, and the maximum
|
||||
`txdelay` factor of `2.0`. The actual randomized transmit wait ranges from zero
|
||||
to ten packet airtimes. Selecting the scope already carried is a no-op and
|
||||
does not grant special treatment. An active radio transmission is not
|
||||
preempted, and CAD and airtime-budget limits still apply.
|
||||
|
||||
The packet is still received and can still be logged. Rules are persistent.
|
||||
While the temporary radio is active, only rows explicitly marked
|
||||
`suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA mode;
|
||||
normal payload types can also use the temporary channel. Other rows remain in
|
||||
force. A malformed persisted table fails open (no general rules are applied).
|
||||
|
||||
**Default row:** Repeater firmware seeds a new flood-filter table with
|
||||
`ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`)
|
||||
@@ -1388,20 +1534,38 @@ set flood.filter.1 0x0C all suspend=tempradio
|
||||
Omitting `all` is equivalent. Omit `.1` as well to reuse an identical rule or
|
||||
the first empty slot instead of replacing slot 1.
|
||||
|
||||
**Remote-admin protection:** `flood.filter` cannot block `anon_req`, `path`, or
|
||||
**Remote-admin protection:** Drop rows cannot block `anon_req`, `path`, or
|
||||
`response` at received hop counts `0-6`; those login-capable types become
|
||||
filterable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and
|
||||
becomes filterable at hop `5`. `req`, `ack`, and multipart ACK have no special
|
||||
blockable at hop `7`. Flood `txt_msg` cannot be blocked at hops `0-4` and
|
||||
becomes blockable at hop `5`. Scope-setting rows are non-blocking and may apply
|
||||
inside these protected ranges. `req`, `ack`, and multipart ACK have no special
|
||||
protection. Transit repeaters cannot decrypt these packets to distinguish an
|
||||
admin exchange from ordinary peer traffic, so each floor necessarily covers
|
||||
all flood packets of that outer type. These exceptions apply only to
|
||||
`flood.filter`; `repeat`, `flood.max*`, region, loop-detection, and other
|
||||
`flood.filter` drop actions; `repeat`, `flood.max*`, loop-detection, and other
|
||||
forwarding gates remain authoritative.
|
||||
|
||||
Without `.n`, `set` returns an identical existing rule, including its suspension
|
||||
setting, or uses the first empty slot. With `.n`, it replaces that slot.
|
||||
Without `.n`, `set` reuses an existing rule with the same match, scope,
|
||||
requirement, and suspension settings, or uses the first empty slot. This lets
|
||||
`tx=slow` or `tx=fast` change that rule's timing without creating a duplicate.
|
||||
With `.n`, it replaces that slot.
|
||||
`get flood.filter` gives a compact list. Use `get flood.filter.<n>` for full
|
||||
details, including `suspend=tempradio`.
|
||||
details, including `path=blacklist`, `scope=`, and `suspend=tempradio`.
|
||||
The detail output also includes `require=region` and `tx=slow` when enabled.
|
||||
|
||||
The blacklist and filter rows are persisted separately. Replacing or deleting
|
||||
the blacklist does not delete rows containing `path=blacklist`; such rows
|
||||
remain dormant while the list is empty. Path hashes are truncated routing
|
||||
identifiers, not authenticated identities, so this is a forwarding signal
|
||||
rather than proof that a particular repeater handled a packet.
|
||||
|
||||
The unnumbered blacklist `set` replaces the whole list and accepts up to 18 IDs
|
||||
so it fits every CLI transport. Numbered `set` writes up to 18 consecutive
|
||||
entries beginning at an existing slot or exactly the next slot, allowing an
|
||||
ESP32 list to grow to 255 entries in batches. Numbered deletion compacts
|
||||
subsequent slots. The unnumbered `get` reports the total and as many leading
|
||||
IDs as fit in one reply; use numbered `get` to inspect entries beyond that
|
||||
reply.
|
||||
|
||||
Standard traceroute is direct-routed and therefore outside `flood.filter`
|
||||
entirely. For a custom flood-form trace, catch-all `any` rows are deliberately
|
||||
@@ -1415,6 +1579,15 @@ set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+
|
||||
set flood.filter ota 2-4
|
||||
set flood.filter.1 0x0C all suspend=tempradio
|
||||
set flood.filter grp_data all suspend=tempradio
|
||||
set flood.filter grp_txt all scope=local
|
||||
set flood.filter grp_data all scope=local require=region
|
||||
set flood.filter grp_data all path=blacklist scope=local tx=slow
|
||||
set flood.filter.blacklist A1B2C3,D4E5F6,112233
|
||||
set flood.filter.blacklist.4 445566
|
||||
set flood.filter.blacklist.19 778899,AABBCC,DDEEFF
|
||||
set flood.filter any all path=blacklist
|
||||
get flood.filter.blacklist
|
||||
get flood.filter.blacklist.4
|
||||
set flood.filter any 12+
|
||||
get flood.filter
|
||||
get flood.filter.2
|
||||
|
||||
+213
-57
@@ -1,8 +1,9 @@
|
||||
# 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.
|
||||
whether this repeater retransmits a packet and can assign a transport scope
|
||||
before that decision. They do not stop local reception, packet logging, or MQTT
|
||||
observation.
|
||||
|
||||
Only flood routes are filtered:
|
||||
|
||||
@@ -28,6 +29,7 @@ get flood.channel.data
|
||||
get flood.channel.data.hops
|
||||
get flood.channel.block
|
||||
get flood.channel.scope
|
||||
get flood.channel.scope.require
|
||||
get flood.filter
|
||||
get flood.moderation
|
||||
```
|
||||
@@ -39,14 +41,14 @@ temporary radio is not synonymous with OTA and can carry normal packet types
|
||||
too. A corrupt or truncated table fails open, so corrupt storage does not
|
||||
silently enable blocking.
|
||||
|
||||
## Force unscoped floods into a transport scope
|
||||
## Force floods into a transport scope
|
||||
|
||||
`flood.channel.scope` can convert a received unscoped flood into a
|
||||
transport-scoped flood before this repeater forwards it:
|
||||
`flood.channel.scope` can add a scope to a received unscoped flood or replace
|
||||
the scope of a transport-scoped flood before this repeater forwards it:
|
||||
|
||||
```text
|
||||
set flood.channel.scope <channel|txt:*|login:*|other:*> <region>
|
||||
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region>
|
||||
set flood.channel.scope <channel|txt:*|login:*|other:*> <region> [tx=slow]
|
||||
set flood.channel.scope.<slot> <channel|txt:*|login:*|other:*> <region> [tx=slow]
|
||||
get flood.channel.scope
|
||||
get flood.channel.scope.<slot>
|
||||
del flood.channel.scope.<slot>
|
||||
@@ -74,29 +76,41 @@ number. A missing or unusable target is skipped, so later exact rows and then
|
||||
the applicable wildcard are tried. Within a wildcard class, the lowest usable
|
||||
duplicate row wins.
|
||||
|
||||
On a successful match, the route changes from `ROUTE_TYPE_FLOOD` to
|
||||
`ROUTE_TYPE_TRANSPORT_FLOOD`. Transport code 0 is calculated with the target
|
||||
region key over the payload type and payload; code 1 remains zero. The change
|
||||
happens before region enforcement, forwarding filters, and deduplication.
|
||||
Consequently `flood.max.unscoped` no longer applies to the rewritten packet,
|
||||
but `flood.max`, target-region permissions, `flood.filter`, channel blocking,
|
||||
loop detection, and moderation still do.
|
||||
On a successful match, an unscoped route changes from `ROUTE_TYPE_FLOOD` to
|
||||
`ROUTE_TYPE_TRANSPORT_FLOOD`; an already-scoped route remains transport-flood
|
||||
but receives replacement codes. Transport code 0 is calculated with the target
|
||||
region key over the payload type and payload, and code 1 becomes zero. The
|
||||
change happens before region enforcement, forwarding filters, and
|
||||
deduplication. Consequently `flood.max.unscoped` no longer applies to a packet
|
||||
converted from unscoped, while `flood.max`, target-region permissions,
|
||||
`flood.filter`, channel blocking, loop detection, and moderation still apply
|
||||
to every rewritten packet. By default, if the selected scope differs and the
|
||||
rewritten packet passes those checks, its initial retransmission uses zero
|
||||
`txdelay` and the highest outbound queue priority so the newly scoped copy can
|
||||
win at the next hop. Add `tx=slow` to use an effective inbound `rxdelay` base
|
||||
of `max(2, configured rxdelay * 2)`, retain normal outbound queue priority, and
|
||||
force the maximum `txdelay` factor of `2.0`. The actual transmit delay is still
|
||||
randomized, from zero through ten packet airtimes.
|
||||
It does not preempt an active radio transmission or bypass CAD and
|
||||
airtime-budget limits. Selecting the scope already present is a no-op and does
|
||||
not grant special treatment.
|
||||
|
||||
Already-scoped floods and direct routes are never rewritten. TRACE is never
|
||||
rewritten even in flood form; its existing code, if any, is preserved and it
|
||||
bypasses region/unknown-code enforcement. Scope assignment also does not
|
||||
override normal payload validation or make an otherwise non-forwardable packet
|
||||
type forwardable.
|
||||
Direct routes are never rewritten. TRACE is never rewritten even in flood
|
||||
form; its existing code, if any, is preserved and it bypasses
|
||||
region/unknown-code enforcement. Scope assignment also does not override
|
||||
normal payload validation or make an otherwise non-forwardable packet type
|
||||
forwardable.
|
||||
|
||||
LoRa OTA (`0x0C`) falls under `other:*`. A matching row adds the selected
|
||||
transport code, but OTA still operates normally during the temporary-radio
|
||||
window because the OTA handler accepts both unscoped and transport-scoped flood
|
||||
routes. The target region must allow flooding. A new repeater also seeds
|
||||
`ota all suspend=tempradio` in flood-filter slot 1. That visible rule blocks OTA
|
||||
forwarding at every received hop outside temporary-radio operation and is
|
||||
skipped while temporary radio is active. Independently, the OTA core refuses
|
||||
OTA receive, relay, and transmit outside an actually active temporary-radio
|
||||
window, even if the seeded row is deleted or replaced.
|
||||
transport code or replaces the existing one, but OTA still operates normally
|
||||
during the temporary-radio window because the OTA handler accepts both
|
||||
unscoped and transport-scoped flood routes. The target region must allow
|
||||
flooding. A new repeater also seeds `ota all suspend=tempradio` in flood-filter
|
||||
slot 1. That visible rule blocks OTA forwarding at every received hop outside
|
||||
temporary-radio operation and is skipped while temporary radio is active.
|
||||
Independently, the OTA core refuses OTA receive, relay, and transmit outside an
|
||||
actually active temporary-radio window, even if the seeded row is deleted or
|
||||
replaced.
|
||||
|
||||
Capacity is selected at build time:
|
||||
|
||||
@@ -112,6 +126,52 @@ The region map still has 32 named-region entries. Large ESP32 tables can map
|
||||
many channels to the same targets, but cannot reference more than 32 distinct
|
||||
configured region names.
|
||||
|
||||
## Require valid incoming scopes only on selected channels
|
||||
|
||||
`flood.channel.scope.require` changes region enforcement for received flood
|
||||
`GRP_TXT` and `GRP_DATA` packets from a global policy to a channel opt-in
|
||||
policy:
|
||||
|
||||
```text
|
||||
set flood.channel.scope.require <public|#channel|128/256-bit-key>
|
||||
set flood.channel.scope.require.<slot> <public|#channel|128/256-bit-key>
|
||||
get flood.channel.scope.require
|
||||
get flood.channel.scope.require.<slot>
|
||||
del flood.channel.scope.require.<slot>
|
||||
del flood.channel.scope.require all
|
||||
```
|
||||
|
||||
An empty table preserves the normal global region behavior. Once at least one
|
||||
row exists, a group-channel packet that authenticates against a listed key must
|
||||
arrive as `ROUTE_TYPE_TRANSPORT_FLOOD` with a transport code matching a locally
|
||||
flood-allowed region. An unscoped packet, an unknown transport code, or a code
|
||||
for a denied region is not retransmitted. The check uses the original incoming
|
||||
scope before `flood.channel.scope` or `flood.filter scope=` can rewrite it.
|
||||
Those rewrite actions are skipped for a rejected listed channel, so they
|
||||
cannot rescue it or grant special receive/transmit timing.
|
||||
|
||||
Group-channel packets that do not authenticate against any listed key bypass
|
||||
the region/unknown-code forwarding gate. They still pass through `repeat`,
|
||||
`flood.max*`, `flood.filter`, `flood.channel.block`, loop detection, payload
|
||||
validation, and moderation. Non-channel flood payload types retain the normal
|
||||
global region behavior. A one-byte channel-hash collision is only a prefilter;
|
||||
the packet must also pass MAC validation/decryption with the configured key.
|
||||
|
||||
Without `.slot`, setting an existing key updates its row and a new key uses the
|
||||
first empty row. Numbered `set` replaces that slot. Detail output displays only
|
||||
the first four derived hash bytes and key size, never the secret. The table has
|
||||
the same build-dependent slot count as `flood.channel.scope`; each row consumes
|
||||
34 bytes of RAM and storage, plus a five-byte file header. ACL permission `4`
|
||||
can manage it.
|
||||
|
||||
For example, this requires an allowed incoming scope on `#bot`, while every
|
||||
other group channel bypasses region enforcement:
|
||||
|
||||
```text
|
||||
set flood.channel.scope.require #bot
|
||||
get flood.channel.scope.require
|
||||
```
|
||||
|
||||
### Interaction with duplicate detection
|
||||
|
||||
The seen-packet hash contains the payload type and exact payload bytes. It does
|
||||
@@ -121,9 +181,10 @@ unscoped packet and the same packet after this repeater adds a transport code
|
||||
are the same duplicate. A later copy with a different scope is also the same
|
||||
duplicate; changing or adding scope cannot evade the seen table.
|
||||
|
||||
When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal receive-quality timing
|
||||
still chooses the packet to process, but that winner takes a scope from the
|
||||
queued copies whose transport code matches an allowed region in this repeater.
|
||||
When equivalent non-TRACE flood copies overlap in `rxdelay`, the normal
|
||||
receive-quality timing still chooses the packet to process, but that winner
|
||||
takes a scope from the queued copies whose transport code matches an allowed
|
||||
region in this repeater.
|
||||
Unknown and denied scopes are ignored. If eligible copies have different
|
||||
scopes, the shortest received path supplies the scope. Equal path lengths
|
||||
prefer the deepest matching child region (the narrowest configured scope). A
|
||||
@@ -136,23 +197,94 @@ comparison. It applies only while copies are queued and cannot alter a copy
|
||||
already processed into the seen table. TRACE is excluded from scope arbitration
|
||||
entirely, so rxdelay never adds or replaces a trace transport code.
|
||||
|
||||
## Filter by payload type and received hop count
|
||||
A packet that already matches a configured fast `flood.channel.scope` or
|
||||
`flood.filter scope=` action and needs its scope changed bypasses this inbound
|
||||
`rxdelay` queue entirely. A `tx=slow` row remains in the queue with twice the
|
||||
configured base, floored at `2.0`, and participates in normal queued-copy
|
||||
scope arbitration.
|
||||
|
||||
Use `flood.filter` when the packet type and its current path length are enough
|
||||
to make the decision:
|
||||
## Filter by payload type, received hop count, and path
|
||||
|
||||
Use `flood.filter` when the packet type, current path length, or listed path
|
||||
identifiers are enough to make the decision:
|
||||
|
||||
```text
|
||||
set flood.filter <type> [hops] [suspend=tempradio]
|
||||
set flood.filter.<slot> <type> [hops] [suspend=tempradio]
|
||||
set flood.filter.blacklist <ID[,ID...]>
|
||||
set flood.filter.blacklist.<slot> <ID[,ID...]>
|
||||
get flood.filter.blacklist
|
||||
get flood.filter.blacklist.<slot>
|
||||
del flood.filter.blacklist
|
||||
del flood.filter.blacklist.<slot>
|
||||
set flood.filter <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]
|
||||
set flood.filter.<slot> <type> [hops] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]
|
||||
get flood.filter
|
||||
get flood.filter.<slot>
|
||||
del flood.filter.<slot>
|
||||
del flood.filter all
|
||||
```
|
||||
|
||||
Without a slot number, `set` reuses an identical rule, including its suspension
|
||||
setting, or selects the first empty slot. With a slot number, it replaces that
|
||||
slot. Omitting the hop expression means `all` (`0-63`).
|
||||
The blacklist holds up to 255 unique 3-byte repeater IDs on ESP32 builds and
|
||||
18 on other builds. Each is written as six hexadecimal digits. For example:
|
||||
|
||||
```text
|
||||
set flood.filter.blacklist A1B2C3,D4E5F6,112233
|
||||
set flood.filter.blacklist.4 445566
|
||||
set flood.filter any all path=blacklist
|
||||
```
|
||||
|
||||
An unnumbered `set` replaces the list with up to 18 IDs, the largest command
|
||||
that fits every CLI transport. A numbered `set` writes a batch of up to 18 IDs
|
||||
beginning at an existing slot or the next consecutive slot. This is how an
|
||||
ESP32 list grows beyond 18. Deleting a numbered entry compacts the entries
|
||||
after it. Unnumbered `get` reports the total and prints the leading IDs that
|
||||
fit; numbered `get` retrieves one specific entry.
|
||||
|
||||
`path=blacklist` is an unordered precondition on that row. With 3-byte path
|
||||
hashes, one or more exact blacklist hits qualifies the packet. With 2-byte
|
||||
path hashes, two or more received path entries must match the first two bytes
|
||||
of listed IDs. Each received entry is counted at most once. A 1-byte path
|
||||
never qualifies. The IDs may occur anywhere in the received path; neither
|
||||
their list order nor their path order matters.
|
||||
|
||||
Without a slot number, `set` reuses a rule with the same match, scope,
|
||||
requirement, and suspension settings, or selects the first empty slot. This
|
||||
lets `tx=slow` or `tx=fast` change that rule's timing without creating a
|
||||
duplicate. With a slot number, it replaces that slot. Omitting the hop
|
||||
expression means `all` (`0-63`).
|
||||
|
||||
A row without `scope=` is the existing drop action. A row with `scope=` is a
|
||||
scope-setting action instead: it adds transport scope to an unscoped packet or
|
||||
replaces the codes on an already-scoped packet. The scope name is normalized
|
||||
with a leading `#`, and the 128-bit transport key is derived directly from that
|
||||
hashtag. The name does not need to exist in the region list and is not added to
|
||||
it. Public names up to 30 characters are accepted; private `$` scopes are not.
|
||||
|
||||
Add `require=region` to a scope row when rewriting must not rescue a packet
|
||||
that the incoming-region gate would reject. The repeater evaluates the packet's
|
||||
original route before any rewrite in that receive pass. An incoming transport
|
||||
scope must match a locally allowed region; an unscoped flood must be allowed by
|
||||
the wildcard region. If the check fails, that scope row is skipped, the filter
|
||||
does not grant its region bypass, and the unchanged packet is allowed to fail
|
||||
normal region enforcement. Other independently configured scope rows still
|
||||
apply in their normal order.
|
||||
|
||||
When multiple scope rows match, the lowest-numbered row wins. Scope rows do not
|
||||
approve a packet: any matching drop row and every remaining forwarding gate can
|
||||
still reject it. A filter-assigned scope is trusted without local region-list
|
||||
validation, but `repeat`, `flood.max`, channel blocking, loop detection, and
|
||||
moderation still apply. By default, a changed scope bypasses inbound `rxdelay`,
|
||||
then is retransmitted with zero `txdelay` and the highest outbound queue
|
||||
priority. Add `tx=slow` to use an effective inbound `rxdelay` base of
|
||||
`max(2, configured rxdelay * 2)`, retain normal queue priority, and force the
|
||||
maximum `txdelay` factor of `2.0`; the randomized transmit delay ranges from
|
||||
zero through ten packet airtimes. `tx=fast` explicitly restores the default.
|
||||
Selecting the scope already present does not grant special treatment. Active
|
||||
radio transmission, CAD, and airtime-budget limits are unchanged.
|
||||
|
||||
The blacklist and rule table are persisted separately. Deleting the blacklist
|
||||
leaves `path=blacklist` rows in place but dormant until IDs are configured
|
||||
again. Path hashes are truncated routing identifiers and are not authenticated
|
||||
proof that a particular repeater handled a packet.
|
||||
|
||||
On first initialization, flood-filter slot 1 is seeded with:
|
||||
|
||||
@@ -169,10 +301,10 @@ on a temporary channel.
|
||||
|
||||
Suspension does not approve a packet or bypass the rest of the filter table. It
|
||||
skips that row, then evaluation continues with the next row and the remaining
|
||||
forwarding gates. An ordinary `any` row therefore still applies during the
|
||||
temporary-radio window, subject to the short-path remote-admin protection
|
||||
below. `repeat`, `flood.max*`, region, loop-detection, and the OTA subsystem's
|
||||
own hop limit also remain in force.
|
||||
forwarding gates. An ordinary drop `any` row therefore still applies during
|
||||
the temporary-radio window, subject to the short-path remote-admin protection
|
||||
below. `repeat`, `flood.max*`, region handling, loop detection, and the OTA
|
||||
subsystem's own hop limit also remain in force.
|
||||
|
||||
Standard traceroute uses direct routing and never enters `flood.filter`. For a
|
||||
custom flood-form trace, catch-all `any` rows are deliberately ignored; only an
|
||||
@@ -181,7 +313,7 @@ flood-forward TRACE packets.
|
||||
|
||||
### Remote administration cannot be type-filtered on short paths
|
||||
|
||||
`flood.filter` uses two minimum filterable hop counts:
|
||||
`flood.filter` drop actions use two minimum filterable hop counts:
|
||||
|
||||
- `anon_req`, `path`, and `response` cannot be blocked at received hops `0-6`;
|
||||
configured rules begin applying at hop `7`.
|
||||
@@ -189,7 +321,8 @@ flood-forward TRACE packets.
|
||||
begin applying at hop `5`.
|
||||
|
||||
`req`, `ack`, and multipart ACK have no special floor and remain filterable from
|
||||
hop `0`.
|
||||
hop `0`. Scope-setting rows do not block traffic and may apply within the
|
||||
protected ranges.
|
||||
|
||||
A flooded login starts as `ANON_REQ`; its reply is commonly a `PATH` packet
|
||||
carrying an encrypted `RESPONSE`. Before a direct return path is established,
|
||||
@@ -199,9 +332,8 @@ from ordinary peer packets with the same outer type. Each hop floor therefore
|
||||
covers the complete outer packet class, not only packets that ultimately
|
||||
authenticate as administrators.
|
||||
|
||||
This protects only against the configurable `flood.filter` table. It does not
|
||||
override `repeat`, `flood.max*`, region enforcement, loop detection, or other
|
||||
forwarding gates.
|
||||
This protects only against configurable `flood.filter` drop actions. It does
|
||||
not override `repeat`, `flood.max*`, loop detection, or other forwarding gates.
|
||||
|
||||
Hop expressions are based on the path count when this repeater receives the
|
||||
packet:
|
||||
@@ -225,6 +357,19 @@ 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
|
||||
|
||||
# Assign #local scope to group text without requiring #local in the region map.
|
||||
set flood.filter grp_txt all scope=local
|
||||
|
||||
# Rewrite only packets whose incoming region was already acceptable.
|
||||
set flood.filter grp_data all scope=local require=region
|
||||
|
||||
# Rewrite matching blacklisted paths without fast-tracking their retransmission.
|
||||
set flood.filter grp_data all path=blacklist scope=local tx=slow
|
||||
|
||||
# Drop matching flood types after the unordered path blacklist qualifies.
|
||||
set flood.filter.blacklist A1B2C3,D4E5F6,112233
|
||||
set flood.filter any all path=blacklist
|
||||
|
||||
# Apply a hard ceiling to flood payload types at 12 or more received hops.
|
||||
set flood.filter any 12+
|
||||
```
|
||||
@@ -362,13 +507,21 @@ the packet contains all path entries listed by the rule.
|
||||
A flood packet is retransmitted only if it passes every applicable control. In
|
||||
other words, the controls combine as deny rules:
|
||||
|
||||
1. `flood.channel.scope` first rewrites a matching unscoped flood packet.
|
||||
2. `repeat`, `flood.max*`, and the channel-data gate are checked.
|
||||
3. `flood.filter` checks payload type and hop range, subject to the login floor
|
||||
of `7` and flood-text floor of `5` described above.
|
||||
4. `flood.channel.block` checks keyed channels.
|
||||
5. Region and loop-detection rules are checked.
|
||||
6. `flood.moderation` checks decrypted group text, username, rate, hops, and path.
|
||||
1. `flood.channel.scope.require` evaluates a listed group channel against the
|
||||
original incoming scope; unlisted group channels bypass the later region
|
||||
gate while the table is active.
|
||||
2. `flood.channel.scope` adds or replaces the scope of a matching flood packet.
|
||||
3. A matching `flood.filter scope=` row may replace that result; its scope does
|
||||
not require a region-list entry.
|
||||
4. `repeat`, `flood.max*`, and the channel-data gate are checked.
|
||||
5. `flood.filter` drop rows check payload type and hop range, subject to the
|
||||
login floor of `7` and flood-text floor of `5` described above.
|
||||
6. `flood.channel.block` checks keyed channels.
|
||||
7. Region and loop-detection rules are checked; a filter-assigned scope is
|
||||
already trusted when it has no region-list match, except that it cannot
|
||||
rescue a channel rejected by `flood.channel.scope.require`.
|
||||
8. `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
|
||||
@@ -388,11 +541,14 @@ A filter manager can read non-secret operational status and manage `repeat`,
|
||||
`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.
|
||||
unrelated administrator settings. Because `flood.filter scope=` derives a
|
||||
public hashtag key directly, a filter manager can configure that action without
|
||||
region-manager permission; it still cannot edit the region hierarchy.
|
||||
|
||||
ACL permission `4`, the region/scope-manager role, can read, add, replace, and
|
||||
delete `flood.channel.scope` rows and manage regions. This lets the same
|
||||
delegate create target regions and assign them to forced-scope rows.
|
||||
delete `flood.channel.scope` and `flood.channel.scope.require` rows and manage
|
||||
regions. This lets the same delegate create target regions, assign forced
|
||||
scopes, and select the channels that require valid incoming scopes.
|
||||
|
||||
## Security limitations
|
||||
|
||||
|
||||
@@ -83,8 +83,9 @@ 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.channel.scope` | Assigns a transport-region scope to received unscoped floods. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region>`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west` |
|
||||
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter.1 0x0C all suspend=tempradio` |
|
||||
| `flood.channel.scope` | Adds a transport-region scope to received unscoped floods or replaces the scope of already-scoped floods. By default, a changed packet bypasses inbound `rxdelay` and is forwarded at the highest outbound queue priority with zero initial `txdelay`, so the selected scope can win at the next hop. `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces the maximum `txdelay` factor of `2.0`; its actual randomized transmit delay ranges from zero through ten packet airtimes. An already-matching scope is a no-op. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region> [tx=slow]`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west tx=slow` |
|
||||
| `flood.channel.scope.require` | Switches group-channel region enforcement to opt-in when the table has entries. Listed authenticated `GRP_TXT`/`GRP_DATA` channels must arrive already scoped to a locally allowed region; unscoped, unknown, or denied incoming scopes are dropped before any rewrite can rescue them. Unlisted group channels bypass only the region gate and retain all other forwarding controls. An empty table preserves global region behavior; non-channel payloads are unchanged. ACL permission `4` can manage the table. | `get flood.channel.scope.require[.n]`, `set flood.channel.scope.require[.n] <public|#channel|key>`, `del flood.channel.scope.require.<n>|all` | `set flood.channel.scope.require #bot` |
|
||||
| `flood.filter` | Persistent repeater-only rules for flood routes `0x00`/`0x01`, selected by payload type, optional received hop count/range (omitted means `all`), and optional unordered `path=blacklist`. The separate blacklist stores up to 255 full 3-byte repeater IDs on ESP32 and 18 on other builds; the path condition qualifies on at least one exact 3-byte hit, at least two 2-byte-prefix hits, and never on 1-byte paths. Rows normally drop matching retransmissions; `scope=<name>` instead adds or replaces a public hashtag-derived scope without requiring a region-list entry. Scope rewrites default to bypassing inbound `rxdelay` and using zero initial `txdelay` plus highest queue priority; `tx=slow` uses an effective inbound `rxdelay` base of `max(2, configured rxdelay * 2)`, retains normal queue priority, and forces `txdelay` factor `2.0`. Adding `require=region` makes that rewrite conditional on the original incoming packet already passing local region enforcement, so unknown or denied incoming scopes are not rescued. New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` drop actions begin at hop `7`; flood `txt_msg` drops begin at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get/set/del flood.filter.blacklist[.n]`, `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [path=blacklist] [scope=<name>] [require=region] [tx=slow] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter grp_txt all scope=local tx=slow` |
|
||||
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
|
||||
| `clock.sync.mesh` | Defaults on for nRF52 repeaters and off for other builds; a saved setting overrides that default. It estimates UTC as soon as the configured number of fresh signed-advert or valid Public-channel sources is collected, with a 30-minute bootstrap/retry timer when evidence is still insufficient, then repeats lazily seven days after each successful estimate. New evidence retriggers evaluation after a no-consensus result. `clock.sync.mesh now` queues an immediate LoRa-only attempt without bypassing quorum or source suppression. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. Status reports the reason a clock was not set; its `.table` and `.1` through `.16` forms inspect collected samples. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `clock.sync.mesh now`, `get clock.sync.status[.table|.1-.16]` | `set clock.sync.mesh on` |
|
||||
| `clock.sync.mesh.edge` | Defaults on so edge repeaters can collect clock evidence when all packets arrive through one relay path. Verified evidence is observed before the forwarding decision, so `repeat off` and forwarding filters do not prevent collection. Signed adverts are deduplicated by public key and Public-channel timestamps by case-insensitive display name; all may share one receive path. Public display names are unauthenticated and can be spoofed. Changing this setting clears current clock samples. | `get clock.sync.mesh.edge`, `set clock.sync.mesh.edge <on|off>` | `set clock.sync.mesh.edge on` |
|
||||
|
||||
+1065
-71
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,14 @@ struct NeighbourInfo {
|
||||
#endif
|
||||
#define FLOOD_PACKET_FILTER_ANY_TYPE 0xFF
|
||||
#define FLOOD_PACKET_FILTER_MAX_HOPS 63
|
||||
#define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32
|
||||
#if defined(ESP32)
|
||||
#define FLOOD_PACKET_FILTER_BLACKLIST_MAX 255
|
||||
#else
|
||||
#define FLOOD_PACKET_FILTER_BLACKLIST_MAX 18
|
||||
#endif
|
||||
#define FLOOD_PACKET_FILTER_BLACKLIST_REPLACE_MAX 18
|
||||
#define FLOOD_PACKET_FILTER_PATH_ID_SIZE 3
|
||||
|
||||
#ifndef FLOOD_CHANNEL_SCOPE_SLOTS
|
||||
#if defined(ESP32)
|
||||
@@ -140,6 +148,9 @@ struct NeighbourInfo {
|
||||
#define FLOOD_CHANNEL_SCOPE_TXT_ANY 0
|
||||
#define FLOOD_CHANNEL_SCOPE_LOGIN_ANY 1
|
||||
#define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2
|
||||
#ifndef FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS
|
||||
#define FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS FLOOD_CHANNEL_SCOPE_SLOTS
|
||||
#endif
|
||||
|
||||
#ifndef FLOOD_GROUP_MODERATION_SLOTS
|
||||
#define FLOOD_GROUP_MODERATION_SLOTS 16
|
||||
@@ -230,6 +241,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
RegionMap region_map, temp_map;
|
||||
RegionEntry* load_stack[8];
|
||||
RegionEntry* recv_pkt_region;
|
||||
bool recv_pkt_filter_scope_set;
|
||||
bool recv_pkt_channel_scope_bypass;
|
||||
bool recv_pkt_channel_scope_rejected;
|
||||
TransportKey default_scope;
|
||||
RateLimiter discover_limiter, anon_limiter;
|
||||
struct FloodRetryBridgeState {
|
||||
@@ -259,6 +273,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
uint8_t min_hops;
|
||||
uint8_t max_hops;
|
||||
bool suspend_on_temp_radio;
|
||||
char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
|
||||
bool match_blacklisted_path;
|
||||
bool scope_requires_region_match;
|
||||
bool scope_uses_slow_timing;
|
||||
};
|
||||
struct FloodChannelScopeEntry {
|
||||
uint16_t region_id; // zero means unused
|
||||
@@ -267,6 +285,11 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
uint8_t channel_hash;
|
||||
uint8_t secret[PUB_KEY_SIZE];
|
||||
};
|
||||
struct FloodChannelScopeRequireEntry {
|
||||
uint8_t key_len; // zero means unused
|
||||
uint8_t channel_hash;
|
||||
uint8_t secret[PUB_KEY_SIZE];
|
||||
};
|
||||
struct FloodGroupModerationEntry {
|
||||
bool active;
|
||||
uint8_t key_len;
|
||||
@@ -295,7 +318,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
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];
|
||||
uint8_t flood_packet_filter_blacklist_count;
|
||||
uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX]
|
||||
[FLOOD_PACKET_FILTER_PATH_ID_SIZE];
|
||||
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
|
||||
FloodChannelScopeRequireEntry
|
||||
flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
|
||||
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
|
||||
ClockSyncSample clock_sync_samples[CLOCK_SYNC_SAMPLE_SLOTS];
|
||||
bool clock_sync_mesh_enabled;
|
||||
@@ -489,22 +517,45 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const;
|
||||
void loadFloodPacketFilters();
|
||||
bool saveFloodPacketFilters();
|
||||
void loadFloodPacketFilterBlacklist();
|
||||
bool saveFloodPacketFilterBlacklist();
|
||||
void seedDefaultFloodPacketFilters();
|
||||
bool floodPacketFilterBlacklistMatches(const mesh::Packet* packet) const;
|
||||
bool floodPacketFilterMatches(const FloodPacketFilterEntry& entry,
|
||||
const mesh::Packet* packet) const;
|
||||
bool applyFloodPacketFilterScope(mesh::Packet* packet, bool incoming_region_allowed,
|
||||
bool& scope_set, bool& fast_track,
|
||||
bool log_change = true);
|
||||
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 formatFloodPacketFilterBlacklist(const char* args, char* reply) const;
|
||||
void setFloodPacketFilterBlacklist(const char* args, char* reply);
|
||||
void deleteFloodPacketFilterBlacklist(const char* args, char* reply);
|
||||
void loadFloodChannelScopes();
|
||||
bool saveFloodChannelScopes(bool empty_table = false);
|
||||
bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry);
|
||||
bool applyFloodChannelScope(mesh::Packet* packet);
|
||||
bool applyFloodChannelScopeTarget(mesh::Packet* packet, const FloodChannelScopeEntry& entry,
|
||||
bool& scope_changed, bool& fast_track,
|
||||
bool log_change = true);
|
||||
bool applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
|
||||
bool log_change = true);
|
||||
static uint8_t scoreFloodTransportScope(const mesh::Packet* packet, void* context);
|
||||
uint8_t getFloodTransportScopeDepth(const mesh::Packet* packet);
|
||||
void formatFloodChannelScopes(const char* args, char* reply);
|
||||
void formatFloodChannelScopeDetail(int index, char* reply, size_t reply_len);
|
||||
void setFloodChannelScope(const char* args, char* reply);
|
||||
void deleteFloodChannelScope(const char* args, char* reply);
|
||||
void loadFloodChannelScopeRequirements();
|
||||
bool saveFloodChannelScopeRequirements(bool empty_table = false);
|
||||
bool findFloodChannelScopeRequirementMatch(const mesh::Packet* packet,
|
||||
bool& table_active) const;
|
||||
void formatFloodChannelScopeRequirements(const char* args, char* reply);
|
||||
void formatFloodChannelScopeRequirementDetail(int index, char* reply,
|
||||
size_t reply_len) const;
|
||||
void setFloodChannelScopeRequirement(const char* args, char* reply);
|
||||
void deleteFloodChannelScopeRequirement(const char* args, char* reply);
|
||||
void loadFloodGroupModeration();
|
||||
bool saveFloodGroupModeration();
|
||||
bool shouldBlockFloodGroupTextForward(const mesh::Packet* packet);
|
||||
@@ -554,6 +605,9 @@ protected:
|
||||
float getAirtimeBudgetFactor() const override {
|
||||
return _prefs.airtime_factor;
|
||||
}
|
||||
bool getCADEnabled() const override {
|
||||
return _prefs.cad_enabled;
|
||||
}
|
||||
|
||||
bool allowPacketForward(const mesh::Packet* packet) override;
|
||||
const char* getLogDateTime() override;
|
||||
@@ -563,8 +617,14 @@ protected:
|
||||
void logTx(mesh::Packet* pkt, int len) override;
|
||||
void logTxFail(mesh::Packet* pkt, int len) override;
|
||||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||||
int calcRxDelayForPacket(const mesh::Packet* packet, float score,
|
||||
uint32_t air_time) override;
|
||||
bool shouldBypassRxDelay(const mesh::Packet* packet) override;
|
||||
bool evaluateScopeRewriteTiming(const mesh::Packet* packet,
|
||||
bool& fast_track);
|
||||
|
||||
uint32_t getRetransmitDelay(const mesh::Packet* packet) override;
|
||||
uint32_t getSlowScopeRetransmitDelay(const mesh::Packet* packet);
|
||||
uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override;
|
||||
bool supportsBasicRetryConfig() const override { return true; }
|
||||
bool supportsAdvancedRetryConfig() const override { return true; }
|
||||
|
||||
@@ -220,12 +220,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t
|
||||
|
||||
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
|
||||
#if MESH_PACKET_LOGGING
|
||||
if (Serial.availableForWrite() > 0) {
|
||||
Serial.print(getLogDateTime());
|
||||
Serial.print(" RAW: ");
|
||||
mesh::Utils::printHex(Serial, raw, len);
|
||||
Serial.println();
|
||||
}
|
||||
// Logging builds prefer backpressure over silently losing a packet record.
|
||||
Serial.print(getLogDateTime());
|
||||
Serial.print(" RAW: ");
|
||||
mesh::Utils::printHex(Serial, raw, len);
|
||||
Serial.println();
|
||||
#endif
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
|
||||
+22
-4
@@ -23,6 +23,18 @@ namespace mesh {
|
||||
#define RADIO_LIVENESS_HARD_MS (12UL * 60UL * 60UL * 1000UL)
|
||||
#endif
|
||||
|
||||
#define MIN_CAD_FAIL_RETRY_DELAY_MS 50UL
|
||||
#define MIN_CAD_FAIL_MAX_DURATION_MS 500UL
|
||||
|
||||
static uint32_t scaleCADDelayForQueue(uint32_t normal_delay, int ready_count,
|
||||
uint32_t minimum_delay) {
|
||||
if (ready_count <= 1) return normal_delay;
|
||||
|
||||
uint32_t scaled = normal_delay / (uint32_t)ready_count;
|
||||
uint32_t floor = normal_delay < minimum_delay ? normal_delay : minimum_delay;
|
||||
return scaled < floor ? floor : scaled;
|
||||
}
|
||||
|
||||
void Dispatcher::begin() {
|
||||
n_sent_flood = n_sent_direct = 0;
|
||||
n_recv_flood = n_recv_direct = 0;
|
||||
@@ -427,7 +439,7 @@ void Dispatcher::checkRecv() {
|
||||
if (pkt->isRouteFlood()) {
|
||||
n_recv_flood++;
|
||||
|
||||
int _delay = calcRxDelay(score, air_time);
|
||||
int _delay = calcRxDelayForPacket(pkt, score, air_time);
|
||||
if (_delay < 50) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(), score delay below threshold (%d)", getLogDateTime(), _delay);
|
||||
processRecvPacket(pkt); // is below the score delay threshold, so process immediately
|
||||
@@ -492,18 +504,24 @@ void Dispatcher::checkSend() {
|
||||
? _radio->isReceivingPassive(getRetryInterferenceMargin())
|
||||
: _radio->isReceiving();
|
||||
if (channel_busy) {
|
||||
const uint32_t cad_now = _ms->getMillis();
|
||||
const int ready_count = _mgr->getOutboundCount(cad_now);
|
||||
if (cad_busy_start == 0) {
|
||||
cad_busy_start = _ms->getMillis(); // record when CAD busy state started
|
||||
cad_busy_start = cad_now; // record when CAD busy state started
|
||||
}
|
||||
|
||||
if (_ms->getMillis() - cad_busy_start > getCADFailMaxDuration()) {
|
||||
const uint32_t max_busy_duration = scaleCADDelayForQueue(
|
||||
getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS);
|
||||
if (cad_now - cad_busy_start > max_busy_duration) {
|
||||
_err_flags |= ERR_EVENT_CAD_TIMEOUT;
|
||||
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): CAD busy max duration reached!", getLogDateTime());
|
||||
// channel activity has gone on too long... (Radio might be in a bad state)
|
||||
// force the pending transmit below...
|
||||
} else {
|
||||
next_tx_time = futureMillis(getCADFailRetryDelay());
|
||||
const uint32_t retry_delay = scaleCADDelayForQueue(
|
||||
getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS);
|
||||
next_tx_time = futureMillis(retry_delay);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,14 @@ protected:
|
||||
|
||||
virtual float getAirtimeBudgetFactor() const;
|
||||
virtual int calcRxDelay(float score, uint32_t air_time) const;
|
||||
virtual bool shouldBypassRxDelay(const Packet* packet) {
|
||||
(void)packet;
|
||||
return false;
|
||||
}
|
||||
virtual int calcRxDelayForPacket(const Packet* packet, float score,
|
||||
uint32_t air_time) {
|
||||
return shouldBypassRxDelay(packet) ? 0 : calcRxDelay(score, air_time);
|
||||
}
|
||||
virtual uint32_t getCADFailRetryDelay() const;
|
||||
virtual uint32_t getCADFailMaxDuration() const;
|
||||
virtual uint8_t getDefaultTxCodingRate() const { return 0; }
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <Packet.h>
|
||||
|
||||
namespace FloodFilterPolicy {
|
||||
|
||||
static constexpr uint8_t BLACKLIST_ID_SIZE = 3;
|
||||
static constexpr uint8_t SLOW_SCOPE_FLAG = 0x80;
|
||||
static constexpr uint8_t SLOW_SCOPE_TX_DELAY_FACTOR = 2;
|
||||
static constexpr float SLOW_SCOPE_RX_DELAY_MIN = 2.0f;
|
||||
static constexpr uint32_t MAX_DISPATCH_DELAY = 0xFFFFFF;
|
||||
|
||||
enum ChannelScopeGate {
|
||||
CHANNEL_SCOPE_USE_GLOBAL,
|
||||
CHANNEL_SCOPE_BYPASS,
|
||||
CHANNEL_SCOPE_REQUIRED_ALLOWED,
|
||||
CHANNEL_SCOPE_REQUIRED_REJECTED,
|
||||
};
|
||||
|
||||
inline ChannelScopeGate channelScopeGate(bool table_active,
|
||||
bool is_group_channel_packet,
|
||||
bool channel_requires_scope,
|
||||
bool incoming_is_scoped,
|
||||
bool incoming_region_allowed) {
|
||||
if (!table_active || !is_group_channel_packet) {
|
||||
return CHANNEL_SCOPE_USE_GLOBAL;
|
||||
}
|
||||
if (!channel_requires_scope) return CHANNEL_SCOPE_BYPASS;
|
||||
return incoming_is_scoped && incoming_region_allowed
|
||||
? CHANNEL_SCOPE_REQUIRED_ALLOWED
|
||||
: CHANNEL_SCOPE_REQUIRED_REJECTED;
|
||||
}
|
||||
|
||||
inline uint8_t encodeScopeSelector(uint8_t selector, bool slow) {
|
||||
return slow ? (uint8_t)(selector | SLOW_SCOPE_FLAG) : selector;
|
||||
}
|
||||
|
||||
inline uint8_t scopeSelectorValue(uint8_t stored_selector) {
|
||||
return stored_selector & (uint8_t)~SLOW_SCOPE_FLAG;
|
||||
}
|
||||
|
||||
inline bool scopeUsesSlowTiming(uint8_t stored_selector) {
|
||||
return (stored_selector & SLOW_SCOPE_FLAG) != 0;
|
||||
}
|
||||
|
||||
inline bool fastTrackScopeChange(bool scope_changed, bool slow) {
|
||||
return scope_changed && !slow;
|
||||
}
|
||||
|
||||
inline float slowScopeRxDelayBase(float configured_base) {
|
||||
float doubled = configured_base * 2.0f;
|
||||
return doubled < SLOW_SCOPE_RX_DELAY_MIN
|
||||
? SLOW_SCOPE_RX_DELAY_MIN : doubled;
|
||||
}
|
||||
|
||||
inline uint32_t slowScopeMaxDelay(uint32_t airtime_millis) {
|
||||
const uint64_t delay = (uint64_t)airtime_millis
|
||||
* SLOW_SCOPE_TX_DELAY_FACTOR * 5U;
|
||||
return delay > MAX_DISPATCH_DELAY
|
||||
? MAX_DISPATCH_DELAY : (uint32_t)delay;
|
||||
}
|
||||
|
||||
inline uint8_t blacklistMatchThreshold(uint8_t path_hash_size) {
|
||||
if (path_hash_size == BLACKLIST_ID_SIZE) return 1;
|
||||
if (path_hash_size == 2) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline bool pathMatchesBlacklist(const mesh::Packet* packet,
|
||||
const uint8_t* blacklist,
|
||||
uint8_t blacklist_count) {
|
||||
if (packet == NULL || blacklist == NULL || blacklist_count == 0) return false;
|
||||
|
||||
const uint8_t hash_size = packet->getPathHashSize();
|
||||
const uint8_t required = blacklistMatchThreshold(hash_size);
|
||||
if (required == 0) return false;
|
||||
|
||||
uint8_t matches = 0;
|
||||
const uint8_t path_hops = packet->getPathHashCount();
|
||||
for (uint8_t hop = 0; hop < path_hops; hop++) {
|
||||
const uint8_t* path_id = &packet->path[hop * hash_size];
|
||||
for (uint8_t i = 0; i < blacklist_count; i++) {
|
||||
const uint8_t* listed_id = &blacklist[i * BLACKLIST_ID_SIZE];
|
||||
if (memcmp(path_id, listed_id, hash_size) == 0) {
|
||||
matches++;
|
||||
if (matches >= required) return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool scopeRuleAllowed(bool requires_region_match,
|
||||
bool incoming_region_allowed) {
|
||||
return !requires_region_match || incoming_region_allowed;
|
||||
}
|
||||
|
||||
inline bool setTransportScope(mesh::Packet* packet, uint16_t transport_code) {
|
||||
if (packet == NULL || !packet->isRouteFlood()) return false;
|
||||
|
||||
const bool changed = packet->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|| packet->transport_codes[0] != transport_code
|
||||
|| packet->transport_codes[1] != 0;
|
||||
if (!changed) return false;
|
||||
|
||||
packet->header =
|
||||
(packet->header & (uint8_t)~PH_ROUTE_MASK) | ROUTE_TYPE_TRANSPORT_FLOOD;
|
||||
packet->transport_codes[0] = transport_code;
|
||||
packet->transport_codes[1] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace FloodFilterPolicy
|
||||
@@ -34,6 +34,7 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation |
|
||||
| `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation |
|
||||
| `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads |
|
||||
| `test_flood_filter_policy` | `src/helpers/FloodFilterPolicy.h` | unordered 3-byte and 2-byte-prefix blacklist matching; match thresholds; repeated path-entry semantics; `require=region` and per-channel scope-gate truth tables; fast/slow scope timing; adding, replacing, and preserving packet scope |
|
||||
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
|
||||
|
||||
## Conventions (and how to add a suite)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/FloodFilterPolicy.h>
|
||||
|
||||
static mesh::Packet makeFloodPacket(uint8_t hash_size,
|
||||
const uint8_t* path,
|
||||
uint8_t path_hops) {
|
||||
mesh::Packet packet;
|
||||
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
|
||||
packet.setPathHashSizeAndCount(hash_size, path_hops);
|
||||
memcpy(packet.path, path, hash_size * path_hops);
|
||||
packet.payload_len = 0;
|
||||
return packet;
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, ThreeBytePathMatchesOneExactIdInAnyPosition) {
|
||||
const uint8_t blacklist[][3] = {
|
||||
{0x10, 0x20, 0x30},
|
||||
{0xAA, 0xBB, 0xCC},
|
||||
{0x70, 0x80, 0x90},
|
||||
};
|
||||
const uint8_t path[] = {
|
||||
0x01, 0x02, 0x03,
|
||||
0x70, 0x80, 0x90,
|
||||
0x04, 0x05, 0x06,
|
||||
};
|
||||
mesh::Packet packet = makeFloodPacket(3, path, 3);
|
||||
|
||||
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&packet, &blacklist[0][0], 3));
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, ThreeBytePathRequiresAnExactThirdByte) {
|
||||
const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0xCC}};
|
||||
const uint8_t path[] = {0xAA, 0xBB, 0xCD};
|
||||
mesh::Packet packet = makeFloodPacket(3, path, 1);
|
||||
|
||||
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&packet, &blacklist[0][0], 1));
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, TwoBytePathRequiresTwoMatchingEntries) {
|
||||
const uint8_t blacklist[][3] = {
|
||||
{0xAA, 0xBB, 0x01},
|
||||
{0x11, 0x22, 0x02},
|
||||
};
|
||||
const uint8_t one_match_path[] = {
|
||||
0xAA, 0xBB,
|
||||
0x33, 0x44,
|
||||
};
|
||||
const uint8_t two_match_path[] = {
|
||||
0x11, 0x22,
|
||||
0x33, 0x44,
|
||||
0xAA, 0xBB,
|
||||
};
|
||||
mesh::Packet one_match = makeFloodPacket(2, one_match_path, 2);
|
||||
mesh::Packet two_matches = makeFloodPacket(2, two_match_path, 3);
|
||||
|
||||
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&one_match, &blacklist[0][0], 2));
|
||||
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&two_matches, &blacklist[0][0], 2));
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, OnePathEntryCountsOnceWhenIdsSharePrefix) {
|
||||
const uint8_t blacklist[][3] = {
|
||||
{0xAA, 0xBB, 0x01},
|
||||
{0xAA, 0xBB, 0x02},
|
||||
};
|
||||
const uint8_t path[] = {0xAA, 0xBB};
|
||||
mesh::Packet packet = makeFloodPacket(2, path, 1);
|
||||
|
||||
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&packet, &blacklist[0][0], 2));
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, RepeatedTwoBytePathEntriesCountSeparately) {
|
||||
const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0x01}};
|
||||
const uint8_t path[] = {
|
||||
0xAA, 0xBB,
|
||||
0xAA, 0xBB,
|
||||
};
|
||||
mesh::Packet packet = makeFloodPacket(2, path, 2);
|
||||
|
||||
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&packet, &blacklist[0][0], 1));
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, OneBytePathsNeverMatch) {
|
||||
const uint8_t blacklist[][3] = {{0xAA, 0xBB, 0xCC}};
|
||||
const uint8_t path[] = {0xAA, 0xAA, 0xAA};
|
||||
mesh::Packet packet = makeFloodPacket(1, path, 3);
|
||||
|
||||
EXPECT_FALSE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&packet, &blacklist[0][0], 1));
|
||||
}
|
||||
|
||||
TEST(FloodFilterBlacklist, Esp32MaximumListIncludesTheLastEntry) {
|
||||
uint8_t blacklist[255][3];
|
||||
for (uint16_t i = 0; i < 255; i++) {
|
||||
blacklist[i][0] = (uint8_t)i;
|
||||
blacklist[i][1] = (uint8_t)(i ^ 0xA5);
|
||||
blacklist[i][2] = (uint8_t)(i ^ 0x5A);
|
||||
}
|
||||
const uint8_t path[] = {
|
||||
blacklist[254][0],
|
||||
blacklist[254][1],
|
||||
blacklist[254][2],
|
||||
};
|
||||
mesh::Packet packet = makeFloodPacket(3, path, 1);
|
||||
|
||||
EXPECT_TRUE(FloodFilterPolicy::pathMatchesBlacklist(
|
||||
&packet, &blacklist[0][0], 255));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, RegionRequirementHasTheExpectedTruthTable) {
|
||||
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, false));
|
||||
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(false, true));
|
||||
EXPECT_FALSE(FloodFilterPolicy::scopeRuleAllowed(true, false));
|
||||
EXPECT_TRUE(FloodFilterPolicy::scopeRuleAllowed(true, true));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, SlowTimingFlagRoundTripsWithoutChangingSelector) {
|
||||
const uint8_t selector = 16;
|
||||
const uint8_t stored =
|
||||
FloodFilterPolicy::encodeScopeSelector(selector, true);
|
||||
|
||||
EXPECT_EQ(selector, FloodFilterPolicy::scopeSelectorValue(stored));
|
||||
EXPECT_TRUE(FloodFilterPolicy::scopeUsesSlowTiming(stored));
|
||||
EXPECT_FALSE(FloodFilterPolicy::scopeUsesSlowTiming(
|
||||
FloodFilterPolicy::encodeScopeSelector(selector, false)));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, OnlyChangedFastRulesReceiveFastTrackTreatment) {
|
||||
EXPECT_TRUE(FloodFilterPolicy::fastTrackScopeChange(true, false));
|
||||
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(true, true));
|
||||
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(false, false));
|
||||
EXPECT_FALSE(FloodFilterPolicy::fastTrackScopeChange(false, true));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, SlowTimingUsesMaximumTxDelayFactor) {
|
||||
EXPECT_EQ(2, FloodFilterPolicy::SLOW_SCOPE_TX_DELAY_FACTOR);
|
||||
EXPECT_EQ(1000U, FloodFilterPolicy::slowScopeMaxDelay(100));
|
||||
EXPECT_EQ(FloodFilterPolicy::MAX_DISPATCH_DELAY,
|
||||
FloodFilterPolicy::slowScopeMaxDelay(0xFFFFFFFFU));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, SlowTimingDoublesRxDelayBaseWithMinimumOfTwo) {
|
||||
EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(0.0f));
|
||||
EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(0.5f));
|
||||
EXPECT_FLOAT_EQ(2.0f, FloodFilterPolicy::slowScopeRxDelayBase(1.0f));
|
||||
EXPECT_FLOAT_EQ(4.0f, FloodFilterPolicy::slowScopeRxDelayBase(2.0f));
|
||||
EXPECT_FLOAT_EQ(20.0f, FloodFilterPolicy::slowScopeRxDelayBase(10.0f));
|
||||
EXPECT_FLOAT_EQ(24.0f, FloodFilterPolicy::slowScopeRxDelayBase(12.0f));
|
||||
EXPECT_FLOAT_EQ(40.0f, FloodFilterPolicy::slowScopeRxDelayBase(20.0f));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, ChannelRequirementTableKeepsLegacyGlobalBehaviorWhenEmpty) {
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_USE_GLOBAL,
|
||||
FloodFilterPolicy::channelScopeGate(false, true, true, false, false));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, ChannelRequirementTableDoesNotChangeOtherPayloadTypes) {
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_USE_GLOBAL,
|
||||
FloodFilterPolicy::channelScopeGate(true, false, false, false, false));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, UnlistedGroupChannelsBypassOnlyTheScopeGate) {
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_BYPASS,
|
||||
FloodFilterPolicy::channelScopeGate(true, true, false, false, false));
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_BYPASS,
|
||||
FloodFilterPolicy::channelScopeGate(true, true, false, true, false));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, ListedChannelsRequireAnAllowedIncomingScope) {
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED,
|
||||
FloodFilterPolicy::channelScopeGate(true, true, true, false, true));
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_REJECTED,
|
||||
FloodFilterPolicy::channelScopeGate(true, true, true, true, false));
|
||||
EXPECT_EQ(
|
||||
FloodFilterPolicy::CHANNEL_SCOPE_REQUIRED_ALLOWED,
|
||||
FloodFilterPolicy::channelScopeGate(true, true, true, true, true));
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, AddsScopeToUnscopedFloodWithoutChangingPayloadType) {
|
||||
mesh::Packet packet;
|
||||
packet.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT);
|
||||
packet.transport_codes[0] = 0;
|
||||
packet.transport_codes[1] = 0;
|
||||
|
||||
EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x1234));
|
||||
EXPECT_EQ(ROUTE_TYPE_TRANSPORT_FLOOD, packet.getRouteType());
|
||||
EXPECT_EQ(PAYLOAD_TYPE_GRP_TXT, packet.getPayloadType());
|
||||
EXPECT_EQ(0x1234, packet.transport_codes[0]);
|
||||
EXPECT_EQ(0, packet.transport_codes[1]);
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, ReplacesExistingScopeAndClearsSecondaryCode) {
|
||||
mesh::Packet packet;
|
||||
packet.header =
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
|
||||
packet.transport_codes[0] = 0x1111;
|
||||
packet.transport_codes[1] = 0x2222;
|
||||
|
||||
EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
|
||||
EXPECT_EQ(0x3333, packet.transport_codes[0]);
|
||||
EXPECT_EQ(0, packet.transport_codes[1]);
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, ExistingExactScopeIsANoOp) {
|
||||
mesh::Packet packet;
|
||||
packet.header =
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
|
||||
packet.transport_codes[0] = 0x3333;
|
||||
packet.transport_codes[1] = 0;
|
||||
|
||||
EXPECT_FALSE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
|
||||
EXPECT_EQ(0x3333, packet.transport_codes[0]);
|
||||
EXPECT_EQ(0, packet.transport_codes[1]);
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, MatchingPrimaryStillChangesWhenSecondaryIsPresent) {
|
||||
mesh::Packet packet;
|
||||
packet.header =
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
|
||||
packet.transport_codes[0] = 0x3333;
|
||||
packet.transport_codes[1] = 0x4444;
|
||||
|
||||
EXPECT_TRUE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
|
||||
EXPECT_EQ(0x3333, packet.transport_codes[0]);
|
||||
EXPECT_EQ(0, packet.transport_codes[1]);
|
||||
}
|
||||
|
||||
TEST(FloodFilterScope, DirectPacketsAreNeverConvertedToFlood) {
|
||||
mesh::Packet packet;
|
||||
packet.header =
|
||||
ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT);
|
||||
packet.transport_codes[0] = 0x1111;
|
||||
packet.transport_codes[1] = 0x2222;
|
||||
|
||||
EXPECT_FALSE(FloodFilterPolicy::setTransportScope(&packet, 0x3333));
|
||||
EXPECT_EQ(ROUTE_TYPE_DIRECT, packet.getRouteType());
|
||||
EXPECT_EQ(0x1111, packet.transport_codes[0]);
|
||||
EXPECT_EQ(0x2222, packet.transport_codes[1]);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -9,15 +9,25 @@ public:
|
||||
|
||||
class TestRadio : public mesh::Radio {
|
||||
public:
|
||||
uint8_t pending_rx[MAX_TRANS_UNIT];
|
||||
int pending_rx_len = 0;
|
||||
int send_starts = 0;
|
||||
bool receiving = false;
|
||||
bool in_recv_mode = true;
|
||||
int soft_recoveries = 0;
|
||||
int hard_recoveries = 0;
|
||||
int cad_set_calls = 0;
|
||||
bool cad_enabled = false;
|
||||
unsigned long last_irq = 0;
|
||||
bool recovery_result = true;
|
||||
|
||||
int recvRaw(uint8_t*, int) override { return 0; }
|
||||
int recvRaw(uint8_t* dest, int max_len) override {
|
||||
if (pending_rx_len == 0) return 0;
|
||||
int len = pending_rx_len < max_len ? pending_rx_len : max_len;
|
||||
memcpy(dest, pending_rx, len);
|
||||
pending_rx_len = 0;
|
||||
return len;
|
||||
}
|
||||
uint32_t getEstAirtimeFor(int) override { return 1; }
|
||||
float packetScore(float, int) override { return 0; }
|
||||
bool startSendRaw(const uint8_t*, int) override { send_starts++; return true; }
|
||||
@@ -25,6 +35,10 @@ public:
|
||||
void onSendFinished() override { }
|
||||
bool isInRecvMode() const override { return in_recv_mode; }
|
||||
bool isReceiving() override { return receiving; }
|
||||
void setCADEnabled(bool enable) override {
|
||||
cad_set_calls++;
|
||||
cad_enabled = enable;
|
||||
}
|
||||
unsigned long getLastRadioInterruptMillis() const override { return last_irq; }
|
||||
bool recoverRadio(bool hard) override {
|
||||
if (hard) {
|
||||
@@ -34,13 +48,35 @@ public:
|
||||
}
|
||||
return recovery_result;
|
||||
}
|
||||
|
||||
void queueRx(const uint8_t* raw, int len) {
|
||||
ASSERT_NE(raw, nullptr);
|
||||
ASSERT_GT(len, 0);
|
||||
ASSERT_LE(len, MAX_TRANS_UNIT);
|
||||
memcpy(pending_rx, raw, len);
|
||||
pending_rx_len = len;
|
||||
}
|
||||
};
|
||||
|
||||
class TestDispatcher : public mesh::Dispatcher {
|
||||
RxReservePacketManager& manager;
|
||||
|
||||
protected:
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; }
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet*) override {
|
||||
received_packets++;
|
||||
return ACTION_RELEASE;
|
||||
}
|
||||
int calcRxDelay(float score, uint32_t air_time) const override {
|
||||
return forced_rx_delay >= 0
|
||||
? forced_rx_delay
|
||||
: mesh::Dispatcher::calcRxDelay(score, air_time);
|
||||
}
|
||||
bool shouldBypassRxDelay(const mesh::Packet*) override {
|
||||
return bypass_rx_delay;
|
||||
}
|
||||
bool getCADEnabled() const override {
|
||||
return configured_cad_enabled;
|
||||
}
|
||||
void onSendFail(mesh::Packet* packet) override {
|
||||
failed_packet = packet;
|
||||
free_count_during_failure = manager.getFreeCount();
|
||||
@@ -49,6 +85,10 @@ protected:
|
||||
public:
|
||||
mesh::Packet* failed_packet = nullptr;
|
||||
int free_count_during_failure = -1;
|
||||
int received_packets = 0;
|
||||
int forced_rx_delay = -1;
|
||||
bool bypass_rx_delay = false;
|
||||
bool configured_cad_enabled = false;
|
||||
|
||||
TestDispatcher(TestRadio& radio, TestClock& clock, RxReservePacketManager& mgr)
|
||||
: mesh::Dispatcher(radio, clock, mgr), manager(mgr) { }
|
||||
@@ -124,6 +164,71 @@ TEST(Dispatcher, ParserAcceptsACompletePacketWithEmptyPayload) {
|
||||
EXPECT_EQ(0U, packet.payload_len);
|
||||
}
|
||||
|
||||
TEST(Dispatcher, FloodPacketWaitsForConfiguredRxDelayWithoutBypass) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.forced_rx_delay = 1000;
|
||||
dispatcher.begin();
|
||||
const uint8_t raw[] = {
|
||||
ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT), 0, 0x42
|
||||
};
|
||||
|
||||
clock.now = 100;
|
||||
radio.queueRx(raw, sizeof(raw));
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(0, dispatcher.received_packets);
|
||||
|
||||
clock.now = 1099;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(0, dispatcher.received_packets);
|
||||
|
||||
clock.now = 1100;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(1, dispatcher.received_packets);
|
||||
}
|
||||
|
||||
TEST(Dispatcher, ScopeRewriteHookBypassesConfiguredRxDelay) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.forced_rx_delay = 1000;
|
||||
dispatcher.bypass_rx_delay = true;
|
||||
dispatcher.begin();
|
||||
const uint8_t raw[] = {
|
||||
ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT), 0, 0x42
|
||||
};
|
||||
|
||||
clock.now = 100;
|
||||
radio.queueRx(raw, sizeof(raw));
|
||||
dispatcher.loop();
|
||||
|
||||
EXPECT_EQ(1, dispatcher.received_packets);
|
||||
EXPECT_EQ(4, manager.getFreeCount());
|
||||
}
|
||||
|
||||
TEST(Dispatcher, ConfiguredCadStateIsPropagatedToTheRadio) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.configured_cad_enabled = true;
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = 1;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(1, radio.cad_set_calls);
|
||||
EXPECT_TRUE(radio.cad_enabled);
|
||||
|
||||
dispatcher.configured_cad_enabled = false;
|
||||
clock.now = 2002;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(2, radio.cad_set_calls);
|
||||
EXPECT_FALSE(radio.cad_enabled);
|
||||
}
|
||||
|
||||
TEST(StaticPoolPacketManager, ReportsEarliestQueueTimesWithoutDequeuing) {
|
||||
StaticPoolPacketManager manager(8);
|
||||
mesh::Packet* later = manager.allocNew();
|
||||
@@ -366,7 +471,7 @@ TEST(StaticPoolPacketManager, RxDelayEqualPathsPreferNarrowerLocalScope) {
|
||||
manager.free(manager.getNextInbound(200));
|
||||
}
|
||||
|
||||
TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) {
|
||||
TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndAdaptiveChannelBackoff) {
|
||||
RxReservePacketManager manager(8, 4);
|
||||
TestClock clock;
|
||||
clock.now = 100;
|
||||
@@ -393,11 +498,62 @@ TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) {
|
||||
EXPECT_EQ(200U, delay_millis);
|
||||
EXPECT_FALSE(dispatcher.queuedWorkDue());
|
||||
|
||||
clock.now = 800;
|
||||
mesh::Packet* second = dispatcher.obtainNewPacket();
|
||||
ASSERT_NE(second, nullptr);
|
||||
second->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
|
||||
second->payload[0] = 0x43;
|
||||
second->payload_len = 1;
|
||||
ASSERT_TRUE(dispatcher.sendPacket(second, 0));
|
||||
|
||||
clock.now = 801;
|
||||
dispatcher.loop();
|
||||
ASSERT_TRUE(dispatcher.nextQueueWakeDelay(delay_millis));
|
||||
EXPECT_EQ(100U, delay_millis);
|
||||
EXPECT_FALSE(dispatcher.queuedWorkDue());
|
||||
|
||||
clock.now = 901;
|
||||
radio.receiving = false;
|
||||
EXPECT_TRUE(dispatcher.queuedWorkDue());
|
||||
}
|
||||
|
||||
TEST(Dispatcher, GrowingQueueShortensCADBusyCeiling) {
|
||||
RxReservePacketManager manager(8, 1);
|
||||
TestClock clock;
|
||||
clock.now = 100;
|
||||
TestRadio radio;
|
||||
radio.receiving = true;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.begin();
|
||||
|
||||
mesh::Packet* first = dispatcher.obtainNewPacket();
|
||||
ASSERT_NE(first, nullptr);
|
||||
first->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
|
||||
first->payload[0] = 0x40;
|
||||
first->payload_len = 1;
|
||||
ASSERT_TRUE(dispatcher.sendPacket(first, 0));
|
||||
clock.now = 101;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(0, radio.send_starts);
|
||||
|
||||
for (uint8_t i = 1; i < 4; i++) {
|
||||
mesh::Packet* packet = dispatcher.obtainNewPacket();
|
||||
ASSERT_NE(packet, nullptr);
|
||||
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
|
||||
packet->payload[0] = (uint8_t)(0x40 + i);
|
||||
packet->payload_len = 1;
|
||||
ASSERT_TRUE(dispatcher.sendPacket(packet, 0));
|
||||
}
|
||||
EXPECT_EQ(4, manager.getOutboundCount(clock.now));
|
||||
|
||||
// Four ready packets reduce the normal four-second CAD ceiling to one
|
||||
// second, measured from the original busy observation.
|
||||
clock.now = 1102;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(1, radio.send_starts);
|
||||
EXPECT_EQ(3, manager.getOutboundCount(clock.now));
|
||||
EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
|
||||
}
|
||||
|
||||
TEST(Dispatcher, SilentRadioEscalatesFromSoftToHardRecovery) {
|
||||
RxReservePacketManager manager(8, 4);
|
||||
TestClock clock;
|
||||
|
||||
Reference in New Issue
Block a user