Pr1.5 tmm nexthop (#10745)

* TrafficManagement: flat unified cache + persistent next-hop overflow store

Reworks the TrafficManagementModule cache layer (policing behaviour unchanged
from upstream) and adds a routing-hint overflow store:

- Flatten the ring: replace the cuckoo-hashed unified cache and the bucketed
  PSRAM NodeInfo index with plain flat arrays + linear scan (same idiom as
  WarmNodeStore). At LoRa packet rates an O(n) scan of the cache is negligible,
  and it removes a large amount of hashing/displacement complexity. The cache
  entry is 11 B; timestamps use a uniform +1 presence-offset so a 0 byte always
  means "empty" across every sub-store. Adds rebaseEpoch() so cached state
  survives the ~19 h relative-timestamp horizon instead of being flushed.

- Next-hop overflow cache: setNextHop/getNextHopHint store a confirmed last-byte
  relay for a destination, written only from NextHopRouter's ACK-confirmed
  decision (and mirrored from TraceRoute). NextHopRouter::getNextHop falls back
  to this cache when the hot NodeDB has no hint, so DMs/relays to long-tail
  nodes keep routing after the node ages out of NodeInfoLite.

- Persistence: preloadNextHopsFromNodeDB warm-starts the cache from persisted
  NodeInfoLite hints on first maintenance pass; next_hop entries are kept alive
  across the maintenance sweep (no TTL) and never clobbered by a stale preload.

All packet-policing logic (rate limit, position dedup, unknown-packet drop,
NodeInfo direct response, hop exhaustion) is the existing upstream behaviour,
untouched. HAS_TRAFFIC_MANAGEMENT defaults on so the module is compiled in. (see note).

Tests: upstream policing suite now actually runs (adds the MeshTypes.h include
that gates HAS_TRAFFIC_MANAGEMENT) plus 4 next-hop tests. Role-aware throttles,
politeness, precision clamp, port-interval and mesh-radius gating — and the
rate-limit >255 saturation fix — are deferred to the advanced-TMM branch.

Note: default dedup movement grid moves to ~91m, which also means 1.5km required to end up with the same signature position - coarser and therefore further than before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* TrafficManagement: fix cppcheck constVariablePointer warning

`node` in preloadNextHopsFromNodeDB() is never written through — mark
it const to satisfy cppcheck's constVariablePointer check in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add multi-hop NextHop recovery tests and unit tests for routing reliability

- Introduced a new test suite for multi-hop NextHop directed-message delivery and relay recovery in `test_nexthop_multihop_recovery.py`. This includes tests for end-to-end delivery and recovery after relay drop.
- Implemented unit tests in `test_main.cpp` for NextHop routing reliability mitigations, covering:
  - M1: Ambiguity-aware last-byte resolution.
  - M2: NextHopRouter's strict-neighbor gate and hop limit checks.
  - M3: Route-health freshness and failure decay.
- Enhanced mock classes to facilitate controlled testing of node behaviors and routing logic.

* grafting fixed

* Address Copilot review for PR #10735 (NextHop improvements)

- docs/nexthop-routing-reliability.md: update status from "no code
  changes yet" to reflect that mitigations and tests are implemented

RAM pressure and MIGRATION_VERBOSE concerns addressed upstream in
PR2.5 (per-platform TRAFFIC_MANAGEMENT_CACHE_SIZE) and PR2 (verbose
default=0) respectively; (0,0) sentinel fixed in PR2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CI: fix cppcheck constVariablePointer and test include path

- NextHopRouter.cpp: qualify two RouteHealth *h locals as const — only
  read for stale-route checks, never mutated through the pointer
- Router.cpp: qualify meshtastic_NodeInfoLite *node as const in
  shouldDecrementHopLimit — only read for favorite/role predicate
- test_position_module/test_main.cpp: change bare PositionModule.h to
  modules/PositionModule.h — build_flags sets -Isrc, not -Isrc/modules,
  so the bare form fails to resolve in the native PlatformIO test env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WarmStore: cache device role + protected category in last_heard low bits

Steal the low 6 bits of WarmNodeEntry.last_heard to carry an evicted node's
device role (4 bits) and a protected category (2 bits) for the hop-trim path,
at zero record-size cost (entry stays 40 B; no RAM/flash growth). The high bits
remain a real unix-seconds timestamp, quantised to 64 s — ample for warm LRU
ordering of long-tail nodes.

- absorb() packs role/protectedCat; place()/ring replay store the raw word so
  metadata round-trips through flash. LRU compares masked time (warmTimeOf).
- take() rehydration masks the metadata bits and restores the cached role so a
  re-admitted node isn't stuck at CLIENT until its next NodeInfo.
- NodeDB classifies the category (favorite/ignored/verified -> Flag;
  tracker/sensor/tak_tracker -> Role) at each eviction site.
- WarmNodeStore::lookupMeta() exposes role/category to consumers.
- Bump WARM_RING_MAGIC (WRNG->WRN2): old rings read as erased and rebuild;
  warm data is a non-critical evictee cache, so discard-on-upgrade is safe.

Tests: test_warm_store 11/11 (new meta round-trip + quantisation-aware ordering);
NodeDB compiles (test_nodedb_blocked 4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: migrate v1 rings/files by discarding last_heard, not the data

Previously the WRNG->WRN2 magic bump treated old rings as erased, discarding all
warm entries — including the PKI public keys that let evicted nodes keep
decrypting DMs. Instead, read v1 (WRNG / WRM1) records and keep each node's
identity + public key, discarding only last_heard (its low bits would otherwise
be misread as the new role/protected metadata). Records re-rank and re-learn
their role on next contact.

- Ring backend (nRF52840): ringReadHeader accepts both magics and reports v1 via
  an out-param; replay zeroes last_heard for v1 records. If the active head page
  is v1, force a rotation so new v2 records never land in a v1-headered page
  (which would discard their freshly-set role on the next load). Legacy pages
  convert to v2 as the ring rotates.
- File backend (warm.dat): bump WARM_STORE_MAGIC WRM1->WRM2; accept WRM1, verify
  CRC against the stored bytes, then discard last_heard and mark dirty so the
  next save rewrites as v2.

Tests: test_warm_store 12/12 (adds test_ws_v1_migration_discardsLastHeard:
key survives, role/protected reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: guard role bit-width + test eviction carries role/protected

- static_assert that the device role enum still fits the 4-bit warm metadata
  field (WARM_ROLE_MASK); fails the build loudly if a new role is added past 15
  rather than silently truncating role on eviction. (Max role today = 12.)
- Add test_migration_carriesRoleAndProtectedIntoWarm: a demoted TRACKER lands in
  the warm tier with its key, role=TRACKER and protected category=Role; a demoted
  CLIENT carries role=CLIENT/None. Exercises the NodeDB eviction path +
  warmProtectedCategory classification (the warm-store unit tests only cover
  absorb() directly).

Tests: test_nodedb_blocked 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix copilot comments

* fix(test): restore #if HAS_TRAFFIC_MANAGEMENT guard in TMM test

The rebase onto PR1.5 lost the top-level HAS_TRAFFIC_MANAGEMENT guard
that PR1.5 introduced, leaving the #else/#endif tail orphaned and
causing compile errors on non-TMM builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Tom
2026-06-19 19:52:58 -05:00
committed by GitHub
co-authored by GitHub Claude Opus 4.8 Ben Meadors
parent ca7d82629d
commit 22072c5f4b
21 changed files with 2529 additions and 737 deletions
+456
View File
@@ -0,0 +1,456 @@
# NextHop direct-message reliability on dense meshes — findings & plan
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
**Date:** 2026-06-13
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
This document captures the analysis and the proposed mitigations so the work can be
continued on this branch by anyone. It is intentionally code-grounded (file:line
references throughout) and standalone — you should not need the original investigation
context to pick it up.
---
## TL;DR
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50100. But
there are **other, equally important issues**: that single byte is trusted blindly at
five different code sites, learned routes **never decay**, routes are learned from the
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
rebroadcasts **amplify congestion** exactly when the mesh is busy.
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
mitigations, M1M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
DM that today silently misroutes or black-holes instead falls back to managed flooding
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
unchanged.
---
## How NextHop routing works today (mechanics)
Inheritance chain: `Router``FloodingRouter``NextHopRouter``ReliableRouter`.
**The single-byte identifiers.** Both routing bytes come from one helper:
```cpp
// src/mesh/NodeDB.h:255
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
```
It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it
never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`,
`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are
`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
**Sending a DM**`NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
**Relaying**`NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
(`:147`). Each node only ever compares against **its own** byte.
**Learning**`NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
the original packet (validated via `PacketHistory::checkRelayers`), set
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
from the **reverse** path's relayer.
**Retransmission / fallback**`NextHopRouter::doRetransmissions`
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
(`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet
**and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
**Dedup / relayer history**`PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
that array.
---
## Root-cause analysis
### 1. The single byte is trusted blindly at five sites (the birthday problem)
| # | Site | File:line | Failure on collision |
| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). |
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime.
### 2. Stale routes never decay
The learned `next_hop` byte is cleared only on the **current DM's** last retry
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
still trusted on the **next** DM's first attempt — which on a congested mesh is also the
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
### 3. Reverse-path (asymmetric-link) learning
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
hop, so the route **flaps** back to the bad value even after a failure reset.
### 4. Congestion amplification
Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window
grows with channel utilization, so retransmit intervals **lengthen** exactly when the
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
meshes, efficiency _is_ reliability.
### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan
develop derives the node number from the public key:
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the
plan rather than changing it:
- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last
byte is uniformly distributed over 256 values. Derivation adds no wire bits.
- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could
renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte
collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_
necessary.
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
candidate gate already skips — so key rotation can't pollute resolution.
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
recover which full node number a colliding value meant — so "detect ambiguity → flood"
remains the correct strategy.
---
## Proposed mitigations
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
(typically 515), so a byte usually resolves unambiguously there; when it doesn't, fall
back to the _safe_ behavior (flood / decrement / don't-learn).
### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive)
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
(near `getMeshNode`, ~2936):
```cpp
enum class LastByteResolution : uint8_t { None, Unique, Ambiguous };
struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; };
// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates.
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE).
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
```
- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`,
the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`,
and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`).
- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid).
- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`,
`num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match
`getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`).
- **Relevance gate:**
- `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0`
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
New constant in `src/mesh/MeshTypes.h` (near line 44):
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
### M2 — Only route on bytes that resolve to a unique, reachable neighbor
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
currently-fresh direct neighbor**; else flood:
```cpp
if (node->next_hop != relay_node) {
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique) return node->next_hop;
LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor");
return std::nullopt;
}
```
This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It
applies to originating, relaying, and retrying, since all route through `getNextHop`.
Apply M1's safe fallback at the other sites:
- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on
`resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't
learn (leave route unset → flood).
- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins"
loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the
resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement**
(safe). Net: removes one full DB scan, adds one resolver scan (wash).
**Left unchanged, by design (document why in code):**
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
(`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
originated; a true fix needs a wider field (out of scope). **This is the one residual
the plan cannot fully close.**
- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
Add a one-line comment; do not change.
### M3 — Route freshness / failure memory (RAM table on NextHopRouter)
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
reuse-oldest discipline (not an unbounded map) to cap RAM.
`src/mesh/NextHopRouter.h` (near `pending`, line 99):
```cpp
struct RouteHealth {
NodeNum dest = 0; // 0 == empty slot
uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware
uint8_t consecutiveFailures = 0;
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to
};
static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth,
// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth
```
Policy:
| Constant | Value | Rationale |
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
| `ROUTE_FAILURE_THRESHOLD` | 3 | 12 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
All age math uses **unsigned subtraction** (rollover-safe, matching
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
gate still applies.
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
`noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures`
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
merely passing through us is not proof that _we_ delivered, and resetting failures there
would reintroduce the asymmetric flap.)
- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the
point a directed delivery has gone un-ACKed for both originator and intermediate) →
`noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately
do **not** `clearRouteHealth` here: keeping the record is what lets the failure count
accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out.
- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())`
(an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and
refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record
exists, so flood-only destinations never pollute the table.
**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of
the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter);
`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The
only place that erases a health record is the `getNextHop` decay path; the retransmission
path leaves it intact so the counter survives a reverse-path re-learn.
### M4 — Earlier flood for unverified routes (gated, off by default)
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
lives in `NextHopRouter.h` and must be flipped to measure:
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
flood on this attempt instead of spending another directed try. A **verified** route
(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
one we already distrust. Off by default precisely so it can be A/B-measured on the
simulator before broad enable.
---
## Files to modify
| File | Change |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` |
| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` |
| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` |
| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 |
| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check |
| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` |
| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) |
**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers,
`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and
`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`).
---
## Edge cases
- **`0x00``0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both
sides, so a `…00` node and a `…FF` node correctly collide on `0xFF``Ambiguous`. Test
explicitly.
- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0`
(`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn
(correct).
- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a
Unique neighbor for M2); admitted to the lenient gate only via favorite/router role.
Safe; self-corrects once `hops_away` is learned.
- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`;
`getNextHop` already early-returns for broadcast.
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
future 256-entry last-byte index is the optimization (not now — RAM).
---
## Verification (all tiers)
### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp`
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
testable without a clock mock.
- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale /
strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self /
skips-ignored / **`0x00``0xFF` collision** / early-exit.
- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt,
split-horizon (relay==next_hop)→nullopt, broadcast→nullopt.
- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap),
failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**,
re-learn-new-hop resets, LRU eviction bound, clear.
- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**;
decrement when the resolved node is not a favorite.
- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop`
returns the stored byte unchanged (proves no happy-path change).
- Re-run `test_packet_history` and `test_hop_scaling` for no regression.
### 2. portduino SimRadio simulator
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a
directed route), stop B relaying that dest, confirm A re-discovers via flood within
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
(attempts-to-delivery, total airtime).
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator
for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
green).
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
### 4. Build / format sanity
native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to
confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into
production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table.
---
## Verification status (as built on `nexthop-redux`)
| Tier | What ran | Result |
| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- |
| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 |
| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 |
| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 |
| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link |
| Format | trunk `clang-format@16.0.3` | ✅ no issues |
| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash |
**Pending (environment-blocked, not yet run):**
- **Multi-hop ABC recovery sim** — the `simulator/` broker hub is **not git-tracked**
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
coverage of their logic but no end-to-end multi-node run yet.
- **Hardware / multi-hop tier** — a committable bench test now exists:
`mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and
asserts delivery recovers after the relay is power-cycled (the M3 path). It
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
NextHop path is genuinely exercised. Collected + verified to skip without hardware;
not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py`
remain the 2-device happy-path/recovery regressions.
---
## Risks & limitations
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
only shrink its frequency.
- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
very dense meshes.
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
simulator A/B before broad enable.
- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight.
- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL
backstop bounds it. Per-hop failure history is future work (more RAM).
---
## How to continue this work (commit sequencing)
Each step is independently testable; land them as separate commits.
1. **M1 resolver + unit tests**`NodeDB` only; no behavior change until wired. Lands the
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
2. **M2 + wiring + tests**`getNextHop` strict gate, learning gate, favorite-router
preservation rewrite. Adds the `getNextHop` and site-4 tests.
3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/
success accounting, reconciliation with the existing last-retry reset. Adds the
route-health unit tests and the simulator recovery check.
4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B
and hardware regression.
Reference plan (with the same content) was developed at
`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this
in-repo doc is the canonical handoff copy.
@@ -0,0 +1,347 @@
"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
This is the hardware/tier-3 validator for the NextHop DM reliability work
(see `docs/nexthop-routing-reliability.md`). The unit suite
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
mesh exercises:
* a directed DM that must traverse a relay is delivered (next_hop routing +
the M1/M2 ambiguity gate + M3 route learning all engage), and
* when the established relay drops and returns, delivery recovers rather than
black-holing (the M3 stale-route decay / re-learn path).
TOPOLOGY REQUIREMENT — why this usually SKIPS:
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
Three co-located radios all hear each other, so A→C is a single direct hop and
next_hop never engages. To run this test the bench must be a *line* — A — B — C
— with the endpoints out of each other's direct RF range (physical distance or
attenuators). The `multihop_topology` fixture detects this automatically: it
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
file is safe to commit and run anywhere — it only *asserts* when the topology
genuinely requires a relay.
REQUIREMENTS:
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
* The relay-recovery test additionally needs uhubctl + a power-controllable
relay port (same gate the other power tests use).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from tests import _power
from tests._port_discovery import resolve_port_by_role
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
def _hops_away(rec: dict[str, Any]) -> int | None:
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
the meshtastic-python version."""
for key in ("hopsAway", "hops_away"):
val = rec.get(key)
if isinstance(val, int):
return val
return None
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
"""Flood a fresh NodeInfo from every node so the whole mesh (including
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
distances. Best-effort — a single node failing to nudge shouldn't abort."""
for _ in range(rounds):
for port in ports:
try:
nudge_nodeinfo_port(port)
except Exception: # noqa: BLE001 — warmup is best-effort
pass
time.sleep(0.5)
time.sleep(settle)
def _wait_for_pubkey(
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
) -> bool:
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
slower than the 2-device case because NodeInfo must be relayed, hence the
longer default deadline."""
deadline = time.monotonic() + deadline_s
last_nudge = time.monotonic()
while time.monotonic() < deadline:
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
if rec.get("user", {}).get("publicKey"):
return True
if time.monotonic() - last_nudge > 20.0:
nudge_nodeinfo_port(rx_port)
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
return False
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
request/PKI/retry pattern."""
from meshtastic.mesh_interface import MeshInterface
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
nudge_nodeinfo_port(rx_port)
tx.broadcast_nodeinfo_ping()
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
return None
for _attempt in range(2):
try:
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
break
except MeshInterface.MeshInterfaceError:
time.sleep(5.0)
else:
return None
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
if pkt is None:
return None
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
return [int(n) for n in (tr.get("route") or [])]
@pytest.fixture(scope="session")
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
"""
roles = sorted(baked_mesh)
if len(roles) < 3:
pytest.skip(
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
"(endpoints out of direct RF range). Add a third role via "
f"--hub-profile. Detected roles: {roles}"
)
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
if any(num is None for _, num in by_role.values()):
pytest.skip("a baked device is missing my_node_num; can't map the topology")
_warm_mesh([port for port, _ in by_role.values()])
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
multihop_pair: tuple[str, str] | None = None
for a_role in roles:
a_port, _ = by_role[a_role]
try:
with connect(port=a_port) as a_iface:
nodes = a_iface.nodesByNum or {}
except Exception: # noqa: BLE001
continue
for c_role in roles:
if c_role == a_role:
continue
_, c_num = by_role[c_role]
hops = _hops_away(nodes.get(c_num, {}))
if hops is not None and hops >= 1:
multihop_pair = (a_role, c_role)
break
if multihop_pair:
break
if not multihop_pair:
pytest.skip(
"no multi-hop pair found — every device appears to be a direct "
"neighbor. Arrange the bench as a line (A — B — C) with the "
"endpoints out of direct RF range (distance or attenuators) so a "
"relay is actually required, then re-run."
)
a_role, c_role = multihop_pair
a_port, _ = by_role[a_role]
c_port, c_num = by_role[c_role]
route = _traceroute_route(a_port, c_num, c_port)
if not route:
pytest.skip(
f"{a_role}{c_role} looked multi-hop but traceroute returned no "
"intermediate relay; can't identify the relay node to drive the "
"recovery test"
)
relay_num = route[0]
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
return {
"tx_role": a_role,
"tx_port": a_port,
"rx_role": c_role,
"rx_port": c_port,
"rx_num": c_num,
"relay_role": relay_role,
"relay_num": relay_num,
}
@pytest.mark.timeout(300)
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
"""A directed wantAck DM that must traverse the relay is delivered.
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
the route is learned from the returning ACK (M3). Retries absorb transient
LoRa loss; the assertion is on eventual delivery.
"""
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
relay_role = multihop_topology["relay_role"]
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip(
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
"multi-hop PKI warmup didn't complete"
)
got = None
for _attempt in range(3):
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == unique,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"multi-hop directed DM {tx_role}{rx_role} via relay "
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
)
@pytest.mark.timeout(600)
def test_multihop_relay_recovery(
multihop_topology: dict[str, Any],
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
) -> None:
"""Delivery recovers after the established relay drops and returns.
Establishes a baseline DM (route via relay learned), powers the relay OFF
(confirming TX survives sending across a downed relay), then powers it back
ON and asserts directed delivery resumes — the M3 stale-route decay /
re-learn path. With a strict A — B — C line there is no path while B is down,
so we only assert TX doesn't crash during the outage; the delivery assertion
is after B returns.
"""
relay_role = multihop_topology["relay_role"]
if not relay_role:
pytest.skip(
"relay node isn't one of the baked hub roles, so it can't be "
"power-cycled; recovery test needs a controllable relay"
)
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
base = f"mh-recover-base-{int(time.time())}"
post = f"mh-recover-post-{int(time.time())}"
# Baseline: confirm delivery works (so the route via the relay is learned)
# before we perturb anything — otherwise a later failure is ambiguous.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
assert (
rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
)
is not None
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
# Power the relay OFF.
try:
_power.power_off(relay_role)
_power.wait_for_absence(relay_role, timeout_s=15.0)
except Exception as exc: # noqa: BLE001
try:
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001
pass
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
# With the only relay down there's no path; we just confirm TX accepts the
# send and survives its internal retries (it must not crash / wedge).
try:
with connect(port=tx_port) as tx_iface:
pkt = tx_iface.sendText(
f"mh-while-down-{int(time.time())}",
destinationId=rx_num,
wantAck=True,
)
assert pkt is not None
time.sleep(8.0) # let retransmissions + route decay run
except Exception as exc: # noqa: BLE001 — restore bench state before failing
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
raise AssertionError(
f"TX crashed sending across a downed relay: {exc}"
) from exc
# Power the relay back ON and let it re-enumerate + boot.
_power.power_on(relay_role)
time.sleep(0.5)
try:
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
pass
time.sleep(8.0)
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
# Delivery should resume once the relay is back (M3 re-learn / decay path).
got = None
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
for _attempt in range(4):
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == post,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(6.0)
assert got is not None, (
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}{rx_role} "
"never resumed — stale-route recovery (M3) may be broken"
)
+1 -1
View File
@@ -34,7 +34,7 @@
enum class TrafficType { POSITION, TELEMETRY };
// Traffic management defaults
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m)
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
// Hop scaling defaults
+5
View File
@@ -45,6 +45,11 @@ enum RxSource {
// For old firmware there is no relay node set
#define NO_RELAY_NODE 0
// How recently we must have heard a direct neighbor for its single-byte relay id to be trusted as a
// unique next hop. Mirrors NUM_ONLINE_SECS (NodeDB.cpp). Used by NodeDB::resolveLastByte() to scope
// last-byte collision resolution to currently-reachable neighbors.
#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2) // 2 hrs
typedef int ErrorCode;
/// Alloc and free packets to our global, ISR safe pool
+194 -5
View File
@@ -98,9 +98,9 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
// destination
if (p->from != 0) {
meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
if (origTx) {
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination
// directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
// when origTx is absent — that lets us still capture the confirmed hop into the TMM overflow cache below.
// Single lookup for both relayer checks on the same (request_id, to) pair
bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
@@ -108,11 +108,28 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
&weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
if (origTx->next_hop != p->relay_node) { // Not already set
// M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
// mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
// now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache —
// the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
// even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
// -> store nothing and keep flooding (safe).
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
if (origTx && origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
origTx->next_hop = p->relay_node;
}
noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route)
#if HAS_TRAFFIC_MANAGEMENT
// Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
// survives even when the source isn't (or is no longer) in the hot NodeDB.
if (trafficManagementModule)
trafficManagementModule->setNextHop(p->from, p->relay_node);
#endif
} else {
LOG_DEBUG("Not learning next hop for 0x%x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
p->relay_node);
}
}
}
@@ -144,6 +161,11 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
if (p->id != 0) {
if (isRebroadcaster()) {
// NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
// cannot be hardened with resolveLastByte() — a remote node that legitimately shares our
// last byte will also match here and rebroadcast. That residual collision needs a wider
// on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
// ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);
@@ -194,15 +216,63 @@ std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
if (isBroadcast(to))
return std::nullopt;
// Hot store first: a direct array hit on the live NodeDB entry.
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
if (node && node->next_hop) {
// M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
// isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
// a health record that still matches the stored byte; a next_hop set by another path (e.g.
// TraceRouteModule) with no matching record is left authoritative.
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) {
LOG_INFO("Next hop 0x%x for 0x%x is stale (age/fails); flood and clear", node->next_hop, to);
node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
// We are careful not to return the relay node as the next hop
if (node->next_hop != relay_node) {
// LOG_DEBUG("Next hop for 0x%x is 0x%x", to, node->next_hop);
// M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
// reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
// would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
// unicast into a void. In both cases flood instead (managed flooding still delivers).
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique)
return node->next_hop;
LOG_WARN("Next hop 0x%x for 0x%x %s; set no pref", node->next_hop, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
} else
LOG_WARN("Next hop for 0x%x is 0x%x, same as relayer; set no pref", to, node->next_hop);
}
#if HAS_TRAFFIC_MANAGEMENT
// Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
// It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
// protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
// reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
// reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
if (trafficManagementModule) {
uint8_t hint = trafficManagementModule->getNextHopHint(to);
if (hint && hint != relay_node) {
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) {
LOG_INFO("TMM next hop 0x%x for 0x%x is stale (age/fails); flood and clear", hint, to);
trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique) {
LOG_DEBUG("Next hop for 0x%x is 0x%x (TMM cache)", to, hint);
return hint;
}
LOG_WARN("TMM next hop 0x%x for 0x%x %s; set no pref", hint, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
}
}
#endif
return std::nullopt;
}
@@ -311,7 +381,10 @@ int32_t NextHopRouter::doRetransmissions()
if (!isBroadcast(p.packet->to)) {
if (p.numRetransmissions == 1) {
// Last retransmission, reset next_hop (fallback to FloodingRouter)
// Last retransmission: this directed delivery went un-ACKed. Record the failure
// (M3 — accumulates across DMs to age out a flapping/dead route) and reset
// next_hop so the final try falls back to FloodingRouter.
noteRouteFailure(p.packet->to);
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
// Also reset it in the nodeDB
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
@@ -319,10 +392,33 @@ int32_t NextHopRouter::doRetransmissions()
LOG_INFO("Resetting next hop for packet with dest 0x%x\n", p.packet->to);
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
}
#if HAS_TRAFFIC_MANAGEMENT
if (trafficManagementModule) {
trafficManagementModule->clearNextHop(p.packet->to);
}
#endif
FloodingRouter::send(packetPool.allocCopy(*p.packet));
} else {
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
// attempt — start flooding one retry sooner to cut recovery latency. A verified
// route (fresh, zero recent failures) keeps the unchanged directed-retry path so
// the sparse-mesh happy path is untouched.
RouteHealth *h = findRouteHealth(p.packet->to);
bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
if (!verified) {
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo)
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
FloodingRouter::send(packetPool.allocCopy(*p.packet));
} else {
NextHopRouter::send(packetPool.allocCopy(*p.packet));
}
#else
NextHopRouter::send(packetPool.allocCopy(*p.packet));
#endif
}
} else {
// Note: we call the superclass version because we don't want to have our version of send() add a new
// retransmission record
@@ -355,3 +451,96 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
printPacket("", pending->packet);
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
}
// ---------------------------------------------------------------------------
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
// slot is never read as infinitely old.
// ---------------------------------------------------------------------------
RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
{
if (dest == 0)
return nullptr;
for (auto &h : routeHealth)
if (h.dest == dest)
return &h;
return nullptr;
}
RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
{
if (dest == 0)
return nullptr;
RouteHealth *oldest = &routeHealth[0];
RouteHealth *freeSlot = nullptr;
for (auto &h : routeHealth) {
if (h.dest == dest)
return &h; // existing record
if (h.dest == 0) {
if (!freeSlot)
freeSlot = &h; // remember the first free slot; prefer it over evicting
continue;
}
// Track the oldest occupied slot in case the table is full (rollover-safe).
if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
oldest = &h;
}
// Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
// so the record is findable.
RouteHealth *slot = freeSlot ? freeSlot : oldest;
*slot = RouteHealth{};
slot->dest = dest;
return slot;
}
void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
{
if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
return;
RouteHealth *h = getOrAllocRouteHealth(dest, now);
if (!h)
return;
// A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
// failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
// out instead of resetting the counter every time.
if (h->lastNextHop != nextHop) {
h->lastNextHop = nextHop;
h->consecutiveFailures = 0;
}
h->learnedAtMsec = now ? now : 1;
}
void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // only routes we actually learned have health to refresh
h->consecutiveFailures = 0;
h->learnedAtMsec = now ? now : 1;
}
void NextHopRouter::noteRouteFailure(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // nothing to penalize (we were flooding, or never learned a route here)
if (h->consecutiveFailures < 255)
h->consecutiveFailures++;
}
bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
{
if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
return true;
return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
}
void NextHopRouter::clearRouteHealth(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (h)
*h = RouteHealth{};
}
+57
View File
@@ -43,6 +43,28 @@ struct PendingPacket {
explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions);
};
/**
* RAM-only per-destination route health. Tracks how fresh a learned next_hop is and how many
* consecutive directed deliveries to it have failed, so getNextHop() can proactively decay a stale or
* repeatedly-failing route back to flooding instead of trusting a dead hop on the next (and on dense
* meshes, slowest) attempt. Not persisted: the learned next_hop itself lives in NodeInfoLite; this is
* just freshness/failure metadata.
*/
struct RouteHealth {
NodeNum dest = 0; ///< destination this record describes; 0 == empty slot
uint32_t learnedAtMsec = 0; ///< millis() when next_hop was last (re)learned (rollover-aware)
uint8_t consecutiveFailures = 0; ///< directed deliveries to `dest` that went un-ACKed
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; ///< the relay byte this health refers to
};
// M4 (optional, off by default): when a route is not proven healthy, fall back to flooding one retry
// earlier instead of spending a second directed attempt. Trades airtime for recovery latency on dense
// meshes; leaves the sparse-mesh happy path (fresh, verified routes) unchanged. Measure on the
// simulator before enabling broadly.
#ifndef NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 0
#endif
class GlobalPacketIdHashFunction
{
public:
@@ -92,12 +114,22 @@ class NextHopRouter : public FloodingRouter
// The number of retransmissions the original sender will do
constexpr static uint8_t NUM_RELIABLE_RETX = 3;
// M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory)
constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B
constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min
constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead
protected:
/**
* Pending retransmissions
*/
std::unordered_map<GlobalPacketId, PendingPacket, GlobalPacketIdHashFunction> pending;
/**
* Per-destination route health (M3). Bounded array, reuse-oldest eviction. RAM-only.
*/
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
/**
* Should this incoming filter be dropped?
*
@@ -142,13 +174,38 @@ class NextHopRouter : public FloodingRouter
void setNextTx(PendingPacket *pending);
// --- M3 route-health helpers (RAM-only). Protected so ReliableRouter (a subclass) can record
// delivery success, and so the unit-test shim can reach them via `using`. All take `now` where
// time matters so the decay logic is pure and testable without a clock mock. ---
/// @return the health record for `dest`, or nullptr if we hold none.
RouteHealth *findRouteHealth(NodeNum dest);
/// @return an existing record for `dest`, else a freshly claimed slot (reuse-oldest on overflow).
RouteHealth *getOrAllocRouteHealth(NodeNum dest, uint32_t now);
/// Record that we (re)learned `nextHop` for `dest`. Resets the failure count only when the hop
/// changed (so a flapping reverse-path re-learn of the same dead hop still ages out).
void noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now);
/// Record an end-to-end delivery success to `dest` (clears failures, refreshes freshness).
void noteRouteSuccess(NodeNum dest, uint32_t now);
/// Record that a directed delivery to `dest` went un-ACKed (no-op if we hold no record).
void noteRouteFailure(NodeNum dest);
/// @return true if the route is too old (TTL) or has failed too many times in a row.
bool isRouteStale(const RouteHealth &h, uint32_t now) const;
/// Forget any health record for `dest`.
void clearRouteHealth(NodeNum dest);
#ifdef PIO_UNIT_TESTING
public: // expose getNextHop to the test shim without widening production visibility
#else
private:
#endif
/**
* Get the next hop for a destination, given the relay node
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
*/
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
private:
/** Check if we should be rebroadcasting this packet if so, do so.
* @return true if we did rebroadcast */
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
+121 -5
View File
@@ -1145,6 +1145,20 @@ void NodeDB::initConfigIntervals()
#endif
}
// Always-on traffic management defaults. Only booleans are written; every
// numeric field stays 0 and resolves to its default_traffic_mgmt_* macro at
// use (e.g. position dedup precision/interval), so fork-wide tuning changes
// take effect without another migration. Rate limiting and the features that
// exhaust or reshape relayed traffic (exhaust_hop_*, drop_unknown_enabled,
// nodeinfo_direct_response) stay opt-in.
static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc)
{
mc.has_traffic_management = true;
mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero;
mc.traffic_management.enabled = true;
mc.traffic_management.position_dedup_enabled = true;
}
void NodeDB::installDefaultModuleConfig()
{
LOG_INFO("Install default ModuleConfig");
@@ -1262,6 +1276,8 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.has_neighbor_info = true;
moduleConfig.neighbor_info.enabled = false;
installTrafficManagementDefaults(moduleConfig);
moduleConfig.has_detection_sensor = true;
moduleConfig.detection_sensor.enabled = false;
moduleConfig.detection_sensor.detection_trigger_type = meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH;
@@ -1613,6 +1629,25 @@ bool NodeDB::enforceSatelliteCaps()
return trimmedAny;
}
// Classify an evicted node's hop-protected category for the warm tier. Favorite/ignored/
// verified are local flags (rarely reach warm — they're eviction-protected — but classify
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
{
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
return static_cast<uint8_t>(WarmProtected::Flag);
if (IS_ONE_OF(n.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR,
meshtastic_Config_DeviceConfig_Role_TAK_TRACKER))
return static_cast<uint8_t>(WarmProtected::Role);
return static_cast<uint8_t>(WarmProtected::None);
}
// The warm tier packs the device role into a 4-bit field (WARM_ROLE_MASK). Fail the build
// loudly if a new role outgrows it, rather than silently truncating role on eviction.
static_assert(_meshtastic_Config_DeviceConfig_Role_MAX <= WARM_ROLE_MASK,
"device role no longer fits the 4-bit warm metadata field");
void NodeDB::cleanupMeshDB()
{
int newPos = 0, removed = 0;
@@ -1639,7 +1674,7 @@ void NodeDB::cleanupMeshDB()
// Keep any key we learned (e.g. via a DM before the NodeInfo
// exchange completed) rather than losing it with the purge.
if (n.public_key.size == 32)
warmStore.absorb(gone, n.last_heard, n.public_key.bytes);
warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n));
#endif
eraseNodeSatellites(gone);
@@ -1822,7 +1857,8 @@ void NodeDB::demoteOldestHotNodesToWarm()
continue;
// Keep the public key if we have one (40 B warm record); keyless nodes
// still get a placeholder so re-admission restores last_heard.
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr);
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role,
warmProtectedCategory(n));
// Demotion drops the node from the header table, so drop its satellites
// too (the eviction chokepoint) — they'd otherwise orphan until the next
// enforceSatelliteCaps pass.
@@ -2226,6 +2262,16 @@ void NodeDB::loadFromDisk()
}
}
// Always-on traffic management: a device that has NEVER configured TMM
// (has_traffic_management false — AdminModule always sets the has_ flag on
// write, even when disabling) gets the fork defaults. Explicitly configured
// devices keep their exact settings.
if (!moduleConfig.has_traffic_management) {
LOG_INFO("Traffic management never configured, installing always-on defaults");
installTrafficManagementDefaults(moduleConfig);
saveToDisk(SEGMENT_MODULECONFIG);
}
state = loadProto(channelFileName, meshtastic_ChannelFile_size, sizeof(meshtastic_ChannelFile), &meshtastic_ChannelFile_msg,
&channelFile);
if (state != LoadFileResult::LOAD_SUCCESS) {
@@ -3295,6 +3341,73 @@ meshtastic_NodeInfoLite *NodeDB::getMeshNode(NodeNum n)
return NULL;
}
ResolvedNode NodeDB::resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor)
{
ResolvedNode result; // defaults to {None, 0}
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel (also what MQTT-sourced packets carry
// when hop_start==0). getLastByteOfNodeNum() never yields 0, so nothing can legitimately match.
if (lastByte == 0)
return result;
const NodeNum self = getNodeNum();
NodeNum firstMatch = 0;
uint8_t matches = 0;
for (size_t i = 0; i < numMeshNodes; i++) {
const meshtastic_NodeInfoLite *node = &meshNodes->at(i);
// Candidate gate: never resolve to ourselves, the sentinels, or an ignored node.
if (node->num == self || node->num == 0 || node->num == NODENUM_BROADCAST)
continue;
if (nodeInfoLiteIsIgnored(node))
continue;
if (getLastByteOfNodeNum(node->num) != lastByte) // cheapest discriminator last
continue;
// Relevance gate: is this node a plausible relay for the requested scope?
bool relevant;
if (requireDirectNeighbor) {
relevant = node->has_hops_away && node->hops_away == 0 && sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS;
} else {
const bool directNeighbor = node->has_hops_away && node->hops_away == 0;
const bool routerRole =
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE);
relevant = directNeighbor || nodeInfoLiteIsFavorite(node) || routerRole;
}
if (!relevant)
continue;
if (++matches == 1) {
firstMatch = node->num;
} else {
// A second relevant candidate shares this byte: ambiguous. No further scanning can
// change that, so stop early and report the collision.
result.status = LastByteResolution::Ambiguous;
result.num = 0;
return result;
}
}
if (matches == 1) {
result.status = LastByteResolution::Unique;
result.num = firstMatch;
}
return result;
}
bool NodeDB::resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum)
{
ResolvedNode r = resolveLastByte(lastByte, requireDirectNeighbor);
if (r.status == LastByteResolution::Unique) {
if (outNum)
*outNum = r.num;
return true;
}
return false;
}
// returns true if the maximum number of nodes is reached or we are running low on memory
bool NodeDB::isFull()
{
@@ -3365,8 +3478,8 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
#if WARM_NODE_COUNT > 0
// Demote to the warm tier so the identity (and crucially the
// PKI key) outlives the hot-store slot.
warmStore.absorb(evicted.num, evicted.last_heard,
evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL);
warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL,
evicted.role, warmProtectedCategory(evicted));
#endif
eraseNodeSatellites(evicted.num);
// Shove the remaining nodes down the chain
@@ -3395,7 +3508,10 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
// Re-admission: restore what the warm tier kept for this node
WarmNodeEntry warm;
if (warmStore.take(n, warm)) {
lite->last_heard = warm.last_heard;
lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits
// Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT
// until the next NodeInfo arrives.
lite->role = static_cast<meshtastic_Config_DeviceConfig_Role>(warmRoleOf(warm));
if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
lite->public_key.size = 32;
memcpy(lite->public_key.bytes, warm.public_key, 32);
+31
View File
@@ -115,6 +115,20 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
/// Given a packet, return how many seconds in the past (vs now) it was received
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
/// Because the wire only carries the last byte of a 32-bit node number, the mapping is ambiguous on
/// dense meshes (the "birthday problem"). Callers must treat Ambiguous and None as "don't trust it".
enum class LastByteResolution : uint8_t {
None, ///< no relevant candidate node has this last byte
Unique, ///< exactly one relevant candidate -> `num` is valid
Ambiguous, ///< two or more relevant candidates collide on this byte
};
struct ResolvedNode {
LastByteResolution status = LastByteResolution::None;
NodeNum num = 0; ///< valid only when status == Unique
};
/// Given a packet, return the number of hops used to reach this node.
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
@@ -331,6 +345,23 @@ class NodeDB
/// with no allocation side effects (unlike getOrCreateMeshNode).
uint32_t hotNodeLastHeard(NodeNum n) const;
/**
* Resolve a single on-wire last-byte (e.g. next_hop / relay_node) back to a unique full NodeNum,
* detecting last-byte collisions instead of silently picking the first match. A 1-byte id only
* needs to be unique among a node's plausible relays, not the whole mesh, so we scope the search:
* - requireDirectNeighbor == true : candidates are direct neighbors (hops_away==0) heard within
* NEXTHOP_NEIGHBOR_FRESH_SECS. Use on the SEND path.
* - requireDirectNeighbor == false : also accept favorites and router-role nodes (unknown hop
* distance allowed). Use when learning / preserving hops.
* Ignored nodes, our own node, and the broadcast/0 sentinels are never candidates. On a tie the
* result is Ambiguous (no tie-break) so callers fall back to flooding rather than misroute.
*/
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
/// Convenience wrapper around resolveLastByte(): true iff exactly one relevant candidate matches.
/// Ambiguous and None both return false (the safe answer for learning / hop preservation).
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
// Thread-safe satellite-map accessors. Return false if absent or the
// corresponding DB is compiled out.
bool copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const;
+5 -1
View File
@@ -486,7 +486,11 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
}
/* Check if a certain node was a relayer of a packet in the history given iterator
* @return true if node was indeed a relayer, false if not */
* @return true if node was indeed a relayer, false if not
* NOTE: intentionally byte-domain. Both `relayer` and relayed_by[] are on-wire last bytes, so this
* answers "did a relayer with this byte touch the packet" — correct without resolving to a NodeNum.
* The collision risk is neutralized where the result is consumed (route learning in
* NextHopRouter::sniffReceived now gates the write through NodeDB::resolveUniqueLastByte). */
bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole)
{
bool found = false;
+4
View File
@@ -151,6 +151,10 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
LOG_DEBUG("Received a %s for 0x%x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
if (ackId) {
stopRetransmission(p->to, ackId);
// M3: an end-to-end ACK proves the directed route to the ACK's sender currently works,
// so clear its failure count and refresh freshness (keeps a good route pinned).
if (!isBroadcast(getFrom(p)))
noteRouteSuccess(getFrom(p), millis());
} else {
stopRetransmission(p->to, nakId);
}
+13 -26
View File
@@ -114,37 +114,24 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
}
#endif
// For subsequent hops, check if previous relay is a favorite router
// Optimized search for favorite routers with matching last byte
// Check ordering optimized for IoT devices (cheapest checks first)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!node)
continue;
// Check 1: is_favorite (cheapest - single bit test)
if (!nodeInfoLiteIsFavorite(node))
continue;
// Check 2: has_user (cheap - single bit test)
if (!nodeInfoLiteHasUser(node))
continue;
// Check 3: role check (moderate cost - multiple comparisons)
if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
// For subsequent hops, preserve hop_limit only when the previous relay is UNAMBIGUOUSLY a favorite
// router. The relay_node byte is just the last byte of a 32-bit node number, so on a dense mesh it
// collides; the old "first matching node wins" scan could preserve hops for the wrong node
// (non-deterministic, depends on NodeDB order). resolveLastByte() reports a collision instead, and
// we re-check the favorite/router predicate on the single resolved node. On ambiguity/none we
// decrement (the safe default).
NodeNum resolved = 0;
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false, &resolved)) {
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(resolved);
if (node && nodeInfoLiteIsFavorite(node) && nodeInfoLiteHasUser(node) &&
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
continue;
}
// Check 4: last byte extraction and comparison (most expensive)
if (nodeDB->getLastByteOfNodeNum(node->num) == p->relay_node) {
// Found a favorite router match
LOG_DEBUG("Identified favorite relay router 0x%x from last byte 0x%x", node->num, p->relay_node);
LOG_DEBUG("Identified unique favorite relay router 0x%x from last byte 0x%x", resolved, p->relay_node);
return false; // Don't decrement hop_limit
}
}
// No favorite router match found, decrement hop_limit
// No unambiguous favorite router match found, decrement hop_limit
return true;
}
+83 -14
View File
@@ -12,7 +12,12 @@
#if defined(NRF52840_XXAA)
#include "flash/flash_nrf5x.h"
#define WARM_RING_MAGIC 0x474E5257u // "WRNG"
#define WARM_RING_MAGIC 0x324E5257u // "WRN2" — v2: last_heard low bits carry role + protected category
#define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" — v1: last_heard was a plain timestamp.
// v1 pages are still read on upgrade: we keep each record's identity + public key but
// DISCARD its last_heard (the old timestamp would be misread as role/protected bits).
// Records re-rank and re-learn their role on the next contact. Legacy pages convert to
// v2 naturally as the ring rotates.
// A tombstone is an entry record whose last_heard is all-ones — getTime()
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
@@ -28,7 +33,10 @@ struct WarmStoreHeader {
};
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
#define WARM_STORE_MAGIC 0x314D5257u // "WRM1"
#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" — v2: last_heard low bits carry role + protected category
#define WARM_STORE_MAGIC_V1 \
0x314D5257u // "WRM1" — v1: last_heard was a plain timestamp. On upgrade we keep
// identity + key but discard last_heard, then rewrite as v2.
#ifdef FSCom
static const char *warmFileName = "/prefs/warm.dat";
@@ -96,11 +104,13 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8
slot = &e;
break;
}
// Compare on the time bits only — the low metadata bits (role/protected) must
// not perturb LRU victim selection.
if (keyIsSet(e.public_key)) {
if (!oldestKeyed || e.last_heard < oldestKeyed->last_heard)
if (!oldestKeyed || warmTimeOf(e) < warmTimeOf(*oldestKeyed))
oldestKeyed = &e;
} else {
if (!oldestKeyless || e.last_heard < oldestKeyless->last_heard)
if (!oldestKeyless || warmTimeOf(e) < warmTimeOf(*oldestKeyless))
oldestKeyless = &e;
}
}
@@ -121,14 +131,28 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8
return slot;
}
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat)
{
const WarmNodeEntry *slot = place(num, lastHeard, key32);
// Pack role + protected category into the low bits of last_heard. place() and ring
// replay store the raw word verbatim, so the metadata round-trips through flash.
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat);
const WarmNodeEntry *slot = place(num, packed, key32);
if (!slot)
return false;
persistEntry(*slot);
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0,
(unsigned)lastHeard, (unsigned)count(), (unsigned)capacity());
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num,
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
(unsigned)count(), (unsigned)capacity());
return true;
}
bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const
{
const WarmNodeEntry *e = find(num);
if (!e)
return false;
role = warmRoleOf(*e);
protectedCat = warmProtOf(*e);
return true;
}
@@ -231,10 +255,22 @@ bool WarmNodeStore::saveIfDirty()
// (stranded live entries re-appended, then erased). Flash access holds spiLock —
// the page cache is shared with InternalFS/LittleFS.
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h) const
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const
{
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
return h.magic == WARM_RING_MAGIC && h.seq != 0xFFFFFFFFu;
if (h.seq == 0xFFFFFFFFu)
return false; // erased page
if (h.magic == WARM_RING_MAGIC) {
if (legacy)
*legacy = false;
return true;
}
if (h.magic == WARM_RING_MAGIC_V1) {
if (legacy)
*legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
return true;
}
return false;
}
// Caller holds spiLock.
@@ -347,11 +383,13 @@ void WarmNodeStore::load()
// Order valid pages by ascending seq so replay applies oldest first
uint8_t order[WARM_FLASH_PAGES] = {};
uint32_t seqs[WARM_FLASH_PAGES] = {};
bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay
uint8_t nValid = 0;
uint8_t nCorrupt = 0;
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
WarmPageHeader h;
if (!ringReadHeader(p, h)) {
bool legacy = false;
if (!ringReadHeader(p, h, &legacy)) {
// An erased page reads back all-ones; any other magic is a
// partially-written or bit-rotted header we're dropping, so flag it
// rather than silently treating the loss as a clean empty ring.
@@ -359,6 +397,7 @@ void WarmNodeStore::load()
nCorrupt++;
continue;
}
legacyOf[p] = legacy;
uint8_t pos = nValid;
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
order[pos] = order[pos - 1];
@@ -382,8 +421,10 @@ void WarmNodeStore::load()
}
uint32_t replayed = 0;
uint32_t migrated = 0;
for (uint8_t k = 0; k < nValid; k++) {
const uint8_t p = order[k];
const bool legacy = legacyOf[p];
uint16_t slot = 0;
for (; slot < kRecordsPerPage; slot++) {
WarmNodeEntry rec;
@@ -400,7 +441,14 @@ void WarmNodeStore::load()
memset(e, 0, sizeof(*e));
}
} else {
const WarmNodeEntry *e = place(rec.num, rec.last_heard, rec.public_key);
// v1 (legacy) record: keep identity + key, but discard the old timestamp —
// its low bits would otherwise be misread as role/protected metadata.
uint32_t lh = rec.last_heard;
if (legacy) {
lh = 0;
migrated++;
}
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
if (e)
pageOf[e - entries] = p;
}
@@ -409,10 +457,17 @@ void WarmNodeStore::load()
activePage = p;
writeSlot = slot;
nextSeq = seqs[k] + 1;
// If the head is a v1 page, force the next append to rotate into a fresh v2 page,
// so new (v2) records never land in a page whose header says v1 (which would make
// a later load discard their last_heard — including the role/protected we just set).
if (legacy)
writeSlot = kRecordsPerPage;
}
}
if (nCorrupt)
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
if (migrated)
LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated);
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
activePage, writeSlot);
}
@@ -479,7 +534,10 @@ void WarmNodeStore::load()
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
return;
}
if (h.magic != WARM_STORE_MAGIC || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
// v1 (WRM1) is still accepted: same record size, but its last_heard was a plain
// timestamp. We keep identity + key and discard last_heard on load (see below).
const bool legacy = (h.magic == WARM_STORE_MAGIC_V1);
if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
f.close();
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
h.entrySize, h.count);
@@ -493,14 +551,25 @@ void WarmNodeStore::load()
LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName);
return;
}
// CRC covers the bytes as written (v1 still has the old last_heard), so check before migrating.
if (crc32Buffer(entries, len) != h.crc) {
LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName);
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
return;
}
if (legacy) {
// Migrate v1 → v2: discard the old last_heard (its low bits would be misread as
// role/protected); keep num + public_key. Mark dirty so save() rewrites as v2.
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
entries[i].last_heard = 0;
dirty = true;
}
} else {
f.close();
}
LOG_INFO("WarmStore: loaded %u warm nodes from %s", h.count, warmFileName);
LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName,
legacy ? " (v1 migrated: discarded last_heard)" : "");
}
bool WarmNodeStore::save()
+49 -3
View File
@@ -34,11 +34,50 @@
*/
struct WarmNodeEntry {
NodeNum num; // 0 = empty slot
uint32_t last_heard; // recency for LRU ordering
uint32_t last_heard; // recency for LRU ordering — see the metadata steal below
uint8_t public_key[32]; // all-zero = no key (a real key is never all-zero)
};
static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B — persistence format depends on it");
// Metadata packed into the low bits of last_heard.
//
// The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute
// recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry
// the evicted node's device role + a protected category, at zero cost to record size
// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds
// timestamp quantised to (1 << WARM_META_BITS) seconds.
//
// Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before
// 2106, and tombstones/erased flash are detected via num before last_heard is read. Only
// the LOW bits are stolen — the high (era) bits are untouched, so the time range is intact.
static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2)
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum
static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0
static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12)
static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category
static constexpr uint32_t WARM_PROT_MASK = 0x03u;
// Protected category cached alongside role so consumers needn't re-derive the mapping.
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 };
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot)
{
return (lastHeard & WARM_TIME_MASK) | (static_cast<uint32_t>(role) & WARM_ROLE_MASK) |
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT);
}
inline uint32_t warmTimeOf(const WarmNodeEntry &e)
{
return e.last_heard & WARM_TIME_MASK;
}
inline uint8_t warmRoleOf(const WarmNodeEntry &e)
{
return static_cast<uint8_t>(e.last_heard & WARM_ROLE_MASK);
}
inline uint8_t warmProtOf(const WarmNodeEntry &e)
{
return static_cast<uint8_t>((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK);
}
// Gated on NRF52840_XXAA: the ring sits at 0xEA000
// valid only on the 1 MB-flash nRF52840.
#if defined(NRF52840_XXAA)
@@ -58,8 +97,15 @@ class WarmNodeStore
/// Remember an evicted hot node. Keyless candidates never displace keyed
/// entries; otherwise the oldest (keyless-first) entry is replaced.
/// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12)
/// @param protectedCat WarmProtected category cached for the hop-trim path
/// @return true if the node was stored or updated
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */);
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0,
uint8_t protectedCat = 0);
/// Look up the cached device role + protected category for a warm node.
/// @return false if the node is not in the warm tier.
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
/// Find and remove an entry (used when the node is re-admitted to the hot store).
bool take(NodeNum num, WarmNodeEntry &out);
@@ -121,7 +167,7 @@ class WarmNodeStore
void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */);
void ringRotate(); // reclaim oldest page, compacting stranded live entries
void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++)
bool ringReadHeader(uint8_t page, WarmPageHeader &h) const;
bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const;
#endif
bool save();
+6 -2
View File
@@ -143,10 +143,14 @@ static inline int get_max_num_nodes()
#define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0]))
// Traffic Management module configuration
// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h
#ifndef HAS_TRAFFIC_MANAGEMENT
// Enabled by default; STM32WL is excluded due to RAM constraints (MAX_NUM_NODES=10).
// Disable per-variant by defining HAS_TRAFFIC_MANAGEMENT=0 in variant.h
#ifdef ARCH_STM32WL
#define HAS_TRAFFIC_MANAGEMENT 0
#endif
#ifndef HAS_TRAFFIC_MANAGEMENT
#define HAS_TRAFFIC_MANAGEMENT 1
#endif
// HopScalingModule - variable hop module: dynamically adjusts broadcast hop_limit based on mesh density
// Enable per-variant by defining HAS_VARIABLE_HOPS=1 in variant.h
+12
View File
@@ -8,6 +8,10 @@
#include "meshUtils.h"
#include <vector>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
extern graphics::Screen *screen;
TraceRouteModule *traceRouteModule;
@@ -323,6 +327,14 @@ void TraceRouteModule::maybeSetNextHop(NodeNum target, uint8_t nextHopByte)
LOG_INFO("Updating next-hop for 0x%08x to 0x%02x based on traceroute", target, nextHopByte);
node->next_hop = nextHopByte;
}
#if HAS_TRAFFIC_MANAGEMENT
// Mirror into the TMM overflow cache. Traceroute is the highest-confidence
// source (full known route), and this captures the target even when it isn't
// in the hot NodeDB — same rationale as the ACK-confirmed path in NextHopRouter.
if (trafficManagementModule)
trafficManagementModule->setNextHop(target, nextHopByte);
#endif
}
void TraceRouteModule::processUpgradedPacket(const meshtastic_MeshPacket &mp)
+268 -425
View File
@@ -28,7 +28,6 @@ namespace
constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval
constexpr uint32_t kUnknownResetMs = 60 * 1000UL; // Unknown packet window
constexpr uint8_t kMaxCuckooKicks = 16; // Max displacement chain length
// NodeInfo direct response: enforced maximum hops by device role
// Both use maxHops logic (respond when hopsAway <= threshold)
@@ -76,6 +75,21 @@ bool isWithinWindow(uint32_t nowMs, uint32_t startMs, uint32_t intervalMs)
return (nowMs - startMs) < intervalMs;
}
/**
* Slide an 8-bit relative timestamp back by a wall-clock slab during epoch rebase.
*
* Entries older than the slab clamp to 0 (then reclaimed by the maintenance sweep);
* live entries keep their reconstructed age minus a sub-tick remainder. Each field
* slides by its own resolution's worth of ticks, so a single slab covers all three.
*/
inline void slideRelativeTime(uint8_t &ticks, uint32_t slabMs, uint16_t resolutionSecs)
{
if (ticks == 0 || resolutionSecs == 0)
return;
uint32_t dec = slabMs / (static_cast<uint32_t>(resolutionSecs) * 1000UL);
ticks = (ticks > dec) ? static_cast<uint8_t>(ticks - dec) : 0;
}
/**
* Truncate lat/lon to specified precision for position deduplication.
*
@@ -203,27 +217,16 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme
#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
TM_LOG_INFO("Allocating NodeInfo cache: target=%u occupancy=%u%% payload=%u bytes (PSRAM) tags=%u bytes (%u-bit, %u slots, "
"%u buckets x %u)",
static_cast<unsigned>(nodeInfoTargetEntries()), static_cast<unsigned>(nodeInfoTargetOccupancyPercent()),
static_cast<unsigned>(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry)),
static_cast<unsigned>(nodeInfoIndexMetadataBudgetBytes()), static_cast<unsigned>(nodeInfoTagBits()),
static_cast<unsigned>(nodeInfoIndexSlots()), static_cast<unsigned>(nodeInfoBucketCount()),
static_cast<unsigned>(nodeInfoBucketSize()));
TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (PSRAM flat array)",
static_cast<unsigned>(nodeInfoTargetEntries()),
static_cast<unsigned>(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry)));
nodeInfoIndex = static_cast<uint8_t *>(calloc(nodeInfoIndexMetadataBudgetBytes(), sizeof(uint8_t)));
if (!nodeInfoIndex) {
TM_LOG_WARN("NodeInfo index allocation failed; direct responses will fall back to NodeDB");
} else {
nodeInfoPayload = static_cast<NodeInfoPayloadEntry *>(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry)));
if (nodeInfoPayload) {
nodeInfoPayloadFromPsram = true;
TM_LOG_INFO("NodeInfo bucketed cuckoo cache ready");
TM_LOG_INFO("NodeInfo PSRAM cache ready");
} else {
TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB");
free(nodeInfoIndex);
nodeInfoIndex = nullptr;
}
}
#else
TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target");
@@ -255,11 +258,6 @@ TrafficManagementModule::~TrafficManagementModule()
delete[] nodeInfoPayload;
nodeInfoPayload = nullptr;
}
if (nodeInfoIndex) {
free(nodeInfoIndex);
nodeInfoIndex = nullptr;
}
}
// =============================================================================
@@ -292,17 +290,11 @@ void TrafficManagementModule::incrementStat(uint32_t *field)
}
// =============================================================================
// Cuckoo Hash Table Operations
// Flat Unified Cache Operations
// =============================================================================
/**
* Find an existing entry for the given node.
*
* Cuckoo hashing guarantees that if an entry exists, it's in one of exactly
* two locations: hash1(node) or hash2(node). This provides O(1) lookup.
*
* @param node NodeNum to search for
* @return Pointer to entry if found, nullptr otherwise
* Find an existing entry for the given node (linear scan).
*/
TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node)
{
@@ -313,35 +305,26 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(N
if (!cache || node == 0)
return nullptr;
// Check primary location
uint16_t h1 = cuckooHash1(node);
if (cache[h1].node == node)
return &cache[h1];
// Check alternate location
uint16_t h2 = cuckooHash2(node);
if (cache[h2].node == node)
return &cache[h2];
for (uint16_t i = 0; i < cacheSize(); i++) {
if (cache[i].node == node)
return &cache[i];
}
return nullptr;
#endif
}
/**
* Find or create an entry for the given node using cuckoo hashing.
* Find or create an entry for the given node.
*
* If the node exists, returns the existing entry. Otherwise, attempts to
* insert a new entry using cuckoo displacement:
*
* 1. Try to insert at h1(node) - if empty, done
* 2. Try to insert at h2(node) - if empty, done
* 3. Kick existing entry from h1 to its alternate location
* 4. Repeat up to kMaxCuckooKicks times
* 5. If cycle detected or max kicks exceeded, evict oldest entry
* One linear pass tracks the match, the first empty slot, and the eviction
* victim. When the cache is full, the victim is the stalest entry (largest
* of its three relative timestamps is smallest), preferring entries without
* a next_hop hint — those hints are the long-tail routing state the cache
* exists to keep, and the maintenance sweep never ages them out.
*
* @param node NodeNum to find or create
* @param isNew Set to true if a new entry was created
* @return Pointer to entry, or nullptr if allocation failed
* @return Pointer to entry, or nullptr if the cache is unavailable
*/
TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew)
{
@@ -351,304 +334,76 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat
*isNew = false;
return nullptr;
#else
if (!cache || node == 0) {
if (isNew)
*isNew = false;
if (!cache || node == 0)
return nullptr;
UnifiedCacheEntry *empty = nullptr;
UnifiedCacheEntry *victim = nullptr;
bool victimHasHop = true;
uint8_t victimRecency = UINT8_MAX;
for (uint16_t i = 0; i < cacheSize(); i++) {
UnifiedCacheEntry &e = cache[i];
if (e.node == node)
return &e;
if (e.node == 0) {
if (!empty)
empty = &e;
continue;
}
if (empty)
continue; // an empty slot beats any victim; stop scoring
const bool hasHop = e.next_hop != 0;
uint8_t recency = e.pos_time;
if (e.rate_time > recency)
recency = e.rate_time;
if (e.unknown_time > recency)
recency = e.unknown_time;
if (!victim || (hasHop == victimHasHop ? recency < victimRecency : !hasHop)) {
victim = &e;
victimHasHop = hasHop;
victimRecency = recency;
}
}
// Check if entry already exists (O(1) lookup)
uint16_t h1 = cuckooHash1(node);
if (cache[h1].node == node) {
if (isNew)
*isNew = false;
return &cache[h1];
}
uint16_t h2 = cuckooHash2(node);
if (cache[h2].node == node) {
if (isNew)
*isNew = false;
return &cache[h2];
}
// Entry doesn't exist - try to insert
// Prefer empty slot at h1
if (cache[h1].node == 0) {
memset(&cache[h1], 0, sizeof(UnifiedCacheEntry));
cache[h1].node = node;
UnifiedCacheEntry *slot = empty ? empty : victim;
if (!slot)
return nullptr;
if (!empty)
TM_LOG_DEBUG("Unified cache full, evicting node 0x%08x", slot->node);
memset(slot, 0, sizeof(UnifiedCacheEntry));
slot->node = node;
if (isNew)
*isNew = true;
return &cache[h1];
}
// Try empty slot at h2
if (cache[h2].node == 0) {
memset(&cache[h2], 0, sizeof(UnifiedCacheEntry));
cache[h2].node = node;
if (isNew)
*isNew = true;
return &cache[h2];
}
// Both slots occupied - perform cuckoo displacement
// Start by kicking entry at h1 to its alternate location
UnifiedCacheEntry displaced = cache[h1];
memset(&cache[h1], 0, sizeof(UnifiedCacheEntry));
cache[h1].node = node;
for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) {
// Find alternate location for displaced entry
uint16_t altH1 = cuckooHash1(displaced.node);
uint16_t altH2 = cuckooHash2(displaced.node);
uint16_t altSlot = (altH1 == h1) ? altH2 : altH1;
if (cache[altSlot].node == 0) {
// Found empty slot - insert displaced entry
cache[altSlot] = displaced;
if (isNew)
*isNew = true;
return &cache[h1];
}
// Kick entry from alternate slot
UnifiedCacheEntry temp = cache[altSlot];
cache[altSlot] = displaced;
displaced = temp;
h1 = altSlot;
}
// Cuckoo cycle detected or max kicks exceeded.
// The displaced entry has no valid cuckoo slot — drop it to preserve cache integrity.
// Placing it at an arbitrary slot would make it unreachable by findEntry().
TM_LOG_DEBUG("Cuckoo cycle, evicting node 0x%08x", displaced.node);
if (isNew)
*isNew = true;
return &cache[cuckooHash1(node)];
return slot;
#endif
}
const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoPayload || !nodeInfoIndex || node == 0)
if (!nodeInfoPayload || node == 0)
return nullptr;
uint16_t payloadIndex = findNodeInfoPayloadIndex(node);
if (payloadIndex >= nodeInfoTargetEntries())
for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) {
if (nodeInfoPayload[i].node == node)
return &nodeInfoPayload[i];
}
return nullptr;
return &nodeInfoPayload[payloadIndex];
#else
(void)node;
return nullptr;
#endif
}
uint16_t TrafficManagementModule::encodeNodeInfoTag(uint16_t payloadIndex) const
{
if (payloadIndex >= nodeInfoTargetEntries())
return 0;
return static_cast<uint16_t>(payloadIndex + 1u);
}
uint16_t TrafficManagementModule::decodeNodeInfoPayloadIndex(uint16_t tag) const
{
if (tag == 0 || tag > nodeInfoTargetEntries())
return UINT16_MAX;
return static_cast<uint16_t>(tag - 1u);
}
uint16_t TrafficManagementModule::getNodeInfoTag(uint16_t slot) const
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoIndex || slot >= nodeInfoIndexSlots())
return 0;
const uint32_t bitOffset = static_cast<uint32_t>(slot) * nodeInfoTagBits();
const uint16_t byteOffset = static_cast<uint16_t>(bitOffset >> 3);
const uint8_t shift = static_cast<uint8_t>(bitOffset & 7u);
uint32_t packed = 0;
if (byteOffset < nodeInfoIndexMetadataBudgetBytes())
packed |= static_cast<uint32_t>(nodeInfoIndex[byteOffset]);
if (static_cast<uint16_t>(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes())
packed |= static_cast<uint32_t>(nodeInfoIndex[byteOffset + 1u]) << 8;
if (static_cast<uint16_t>(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes())
packed |= static_cast<uint32_t>(nodeInfoIndex[byteOffset + 2u]) << 16;
return static_cast<uint16_t>((packed >> shift) & nodeInfoTagMask());
#else
(void)slot;
return 0;
#endif
}
void TrafficManagementModule::setNodeInfoTag(uint16_t slot, uint16_t tag)
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoIndex || slot >= nodeInfoIndexSlots())
return;
const uint16_t normalizedTag = static_cast<uint16_t>(tag & nodeInfoTagMask());
const uint32_t bitOffset = static_cast<uint32_t>(slot) * nodeInfoTagBits();
const uint16_t byteOffset = static_cast<uint16_t>(bitOffset >> 3);
const uint8_t shift = static_cast<uint8_t>(bitOffset & 7u);
uint32_t packed = 0;
if (byteOffset < nodeInfoIndexMetadataBudgetBytes())
packed |= static_cast<uint32_t>(nodeInfoIndex[byteOffset]);
if (static_cast<uint16_t>(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes())
packed |= static_cast<uint32_t>(nodeInfoIndex[byteOffset + 1u]) << 8;
if (static_cast<uint16_t>(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes())
packed |= static_cast<uint32_t>(nodeInfoIndex[byteOffset + 2u]) << 16;
const uint32_t mask = static_cast<uint32_t>(nodeInfoTagMask()) << shift;
packed = (packed & ~mask) | ((static_cast<uint32_t>(normalizedTag) << shift) & mask);
if (byteOffset < nodeInfoIndexMetadataBudgetBytes())
nodeInfoIndex[byteOffset] = static_cast<uint8_t>(packed & 0xFFu);
if (static_cast<uint16_t>(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes())
nodeInfoIndex[byteOffset + 1u] = static_cast<uint8_t>((packed >> 8) & 0xFFu);
if (static_cast<uint16_t>(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes())
nodeInfoIndex[byteOffset + 2u] = static_cast<uint8_t>((packed >> 16) & 0xFFu);
#else
(void)slot;
(void)tag;
#endif
}
uint16_t TrafficManagementModule::findNodeInfoPayloadIndex(NodeNum node) const
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoPayload || !nodeInfoIndex || node == 0)
return UINT16_MAX;
const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)};
for (uint8_t b = 0; b < 2; b++) {
const uint16_t base = static_cast<uint16_t>(buckets[b] * nodeInfoBucketSize());
for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) {
uint16_t tag = getNodeInfoTag(static_cast<uint16_t>(base + slot));
if (tag == 0)
continue;
uint16_t payloadIndex = decodeNodeInfoPayloadIndex(tag);
if (payloadIndex >= nodeInfoTargetEntries())
continue;
if (nodeInfoPayload[payloadIndex].node == node)
return payloadIndex;
}
}
return UINT16_MAX;
#else
(void)node;
return UINT16_MAX;
#endif
}
bool TrafficManagementModule::removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex)
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoIndex || node == 0 || payloadIndex >= nodeInfoTargetEntries())
return false;
const uint16_t payloadTag = encodeNodeInfoTag(payloadIndex);
if (payloadTag == 0)
return false;
const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)};
for (uint8_t b = 0; b < 2; b++) {
const uint16_t base = static_cast<uint16_t>(buckets[b] * nodeInfoBucketSize());
for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) {
const uint16_t indexSlot = static_cast<uint16_t>(base + slot);
if (getNodeInfoTag(indexSlot) == payloadTag) {
setNodeInfoTag(indexSlot, 0);
return true;
}
}
}
return false;
#else
(void)node;
(void)payloadIndex;
return false;
#endif
}
uint16_t TrafficManagementModule::allocateNodeInfoPayloadSlot()
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoPayload)
return UINT16_MAX;
for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) {
uint16_t idx = static_cast<uint16_t>((nodeInfoAllocHint + tries) % nodeInfoTargetEntries());
if (nodeInfoPayload[idx].node == 0) {
nodeInfoAllocHint = static_cast<uint16_t>((idx + 1u) % nodeInfoTargetEntries());
return idx;
}
}
#endif
return UINT16_MAX;
}
uint16_t TrafficManagementModule::evictNodeInfoPayloadSlot()
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoPayload || !nodeInfoIndex)
return UINT16_MAX;
for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) {
uint16_t idx = static_cast<uint16_t>(nodeInfoEvictCursor % nodeInfoTargetEntries());
nodeInfoEvictCursor = static_cast<uint16_t>((nodeInfoEvictCursor + 1u) % nodeInfoTargetEntries());
NodeNum oldNode = nodeInfoPayload[idx].node;
if (oldNode == 0)
continue;
removeNodeInfoIndexEntry(oldNode, idx); // best effort; cache tolerates occasional stale miss
nodeInfoPayload[idx].node = 0;
return idx;
}
#endif
return UINT16_MAX;
}
bool TrafficManagementModule::tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag)
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoIndex || !nodeInfoPayload || bucket >= nodeInfoBucketCount() || tag == 0)
return false;
const uint16_t base = static_cast<uint16_t>(bucket * nodeInfoBucketSize());
for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) {
const uint16_t indexSlot = static_cast<uint16_t>(base + slot);
const uint16_t existingTag = getNodeInfoTag(indexSlot);
if (existingTag == 0) {
setNodeInfoTag(indexSlot, tag);
return true;
}
// Opportunistically reuse stale tags that point at empty/invalid payload slots.
const uint16_t payloadIndex = decodeNodeInfoPayloadIndex(existingTag);
if (payloadIndex >= nodeInfoTargetEntries() || nodeInfoPayload[payloadIndex].node == 0) {
setNodeInfoTag(indexSlot, tag);
return true;
}
}
#else
(void)bucket;
(void)tag;
#endif
return false;
}
/**
* Find or create a NodeInfo payload entry (linear scan of the flat PSRAM
* array). One pass tracks the match, the first empty slot, and the LRU
* victim by lastObservedMs (wrap-safe age). NodeInfo traffic is low-rate,
* so the O(n) scan is negligible.
*/
TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node,
bool *usedEmptySlot)
{
@@ -656,88 +411,40 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr
*usedEmptySlot = false;
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoPayload || !nodeInfoIndex || node == 0)
if (!nodeInfoPayload || node == 0)
return nullptr;
uint16_t existing = findNodeInfoPayloadIndex(node);
if (existing < nodeInfoTargetEntries())
return &nodeInfoPayload[existing];
NodeInfoPayloadEntry *empty = nullptr;
NodeInfoPayloadEntry *lru = nullptr;
uint32_t lruAge = 0;
const uint32_t now = millis();
const uint16_t beforeCount = countNodeInfoEntriesLocked();
for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) {
NodeInfoPayloadEntry &e = nodeInfoPayload[i];
if (e.node == node)
return &e;
if (e.node == 0) {
if (!empty)
empty = &e;
continue;
}
if (empty)
continue; // an empty slot beats any victim; stop scoring
const uint32_t age = now - e.lastObservedMs; // unsigned subtraction is wrap-safe
if (!lru || age > lruAge) {
lru = &e;
lruAge = age;
}
}
uint16_t payloadIndex = allocateNodeInfoPayloadSlot();
if (payloadIndex == UINT16_MAX) {
payloadIndex = evictNodeInfoPayloadSlot();
if (payloadIndex == UINT16_MAX)
NodeInfoPayloadEntry *slot = empty ? empty : lru;
if (!slot)
return nullptr;
}
nodeInfoPayload[payloadIndex].node = node;
// 4-way bucketed cuckoo insertion mirrors Cuckoo Filter practice from
// Fan et al. (CoNEXT 2014): high occupancy with short relocation chains.
uint16_t pending = encodeNodeInfoTag(payloadIndex);
uint16_t h1 = nodeInfoHash1(node);
uint16_t h2 = nodeInfoHash2(node);
if (!tryInsertNodeInfoEntryInBucket(h1, pending) && !tryInsertNodeInfoEntryInBucket(h2, pending)) {
uint16_t currentBucket = h1;
for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) {
const uint16_t base = static_cast<uint16_t>(currentBucket * nodeInfoBucketSize());
const uint16_t kickSlot = static_cast<uint16_t>((node + kicks) & (nodeInfoBucketSize() - 1u));
const uint16_t pos = static_cast<uint16_t>(base + kickSlot);
uint16_t displaced = getNodeInfoTag(pos);
setNodeInfoTag(pos, pending);
pending = displaced;
uint16_t displacedPayload = decodeNodeInfoPayloadIndex(pending);
if (displacedPayload >= nodeInfoTargetEntries()) {
pending = 0;
break;
}
NodeNum displacedNode = nodeInfoPayload[displacedPayload].node;
if (displacedNode == 0) {
pending = 0;
break;
}
uint16_t altH1 = nodeInfoHash1(displacedNode);
uint16_t altH2 = nodeInfoHash2(displacedNode);
uint16_t altBucket = (altH1 == currentBucket) ? altH2 : altH1;
if (tryInsertNodeInfoEntryInBucket(altBucket, pending)) {
pending = 0;
break;
}
currentBucket = altBucket;
}
if (pending != 0) {
uint16_t droppedPayload = decodeNodeInfoPayloadIndex(pending);
if (droppedPayload < nodeInfoTargetEntries())
nodeInfoPayload[droppedPayload].node = 0;
TM_LOG_DEBUG("NodeInfo bucketed cuckoo overflow, dropped payload idx=%u",
static_cast<unsigned>(droppedPayload < nodeInfoTargetEntries() ? droppedPayload : UINT16_MAX));
}
}
uint16_t finalIndex = findNodeInfoPayloadIndex(node);
if (finalIndex >= nodeInfoTargetEntries()) {
// New entry did not survive insertion chain.
if (payloadIndex < nodeInfoTargetEntries() && nodeInfoPayload[payloadIndex].node == node)
nodeInfoPayload[payloadIndex].node = 0;
return nullptr;
}
if (usedEmptySlot) {
const uint16_t afterCount = countNodeInfoEntriesLocked();
*usedEmptySlot = afterCount > beforeCount;
}
return &nodeInfoPayload[finalIndex];
memset(slot, 0, sizeof(NodeInfoPayloadEntry));
slot->node = node;
if (usedEmptySlot)
*usedEmptySlot = (slot == empty);
return slot;
#else
(void)node;
return nullptr;
@@ -747,12 +454,12 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr
uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoIndex)
if (!nodeInfoPayload)
return 0;
uint16_t count = 0;
for (uint16_t i = 0; i < nodeInfoIndexSlots(); i++) {
if (getNodeInfoTag(i) != 0)
for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) {
if (nodeInfoPayload[i].node != 0)
count++;
}
return count;
@@ -764,7 +471,7 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const
void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp)
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (!nodeInfoPayload || !nodeInfoIndex || mp.decoded.payload.size == 0)
if (!nodeInfoPayload || mp.decoded.payload.size == 0)
return;
meshtastic_User user = meshtastic_User_init_zero;
@@ -797,16 +504,99 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m
}
if (usedEmptySlot) {
TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u target (%u packed slots, %u-bit tags, %u-byte DRAM index)",
static_cast<unsigned>(cachedCount), static_cast<unsigned>(nodeInfoTargetEntries()),
static_cast<unsigned>(nodeInfoIndexSlots()), static_cast<unsigned>(nodeInfoTagBits()),
static_cast<unsigned>(nodeInfoIndexMetadataBudgetBytes()));
TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u", static_cast<unsigned>(cachedCount),
static_cast<unsigned>(nodeInfoTargetEntries()));
}
#else
(void)mp;
#endif
}
// =============================================================================
// Next-Hop Overflow Cache
// =============================================================================
//
// A routing hint store. The byte is the last byte of the NodeNum to use as next
// hop to reach `dest`. It is written ONLY from NextHopRouter's ACK-confirmed
// decision (a bidirectionally-verified relay) — never inferred one-way from
// relayed traffic. The TMM cache holds confirmed next-hops that have aged out of
// the hot NodeDB (NodeInfoLite), and NextHopRouter::getNextHop() consults it as a
// fallback after the hot store.
void TrafficManagementModule::setNextHop(NodeNum dest, uint8_t nextHopByte)
{
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
if (!cache || dest == 0 || nextHopByte == 0)
return;
concurrency::LockGuard guard(&cacheLock);
bool isNew = false;
UnifiedCacheEntry *entry = findOrCreateEntry(dest, &isNew);
if (entry)
entry->next_hop = nextHopByte; // last-write-wins; only confirmed bytes reach here
#else
(void)dest;
(void)nextHopByte;
#endif
}
uint8_t TrafficManagementModule::getNextHopHint(NodeNum dest)
{
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
if (!cache || dest == 0)
return 0;
concurrency::LockGuard guard(&cacheLock);
UnifiedCacheEntry *entry = findEntry(dest);
return entry ? entry->next_hop : 0;
#else
(void)dest;
return 0;
#endif
}
void TrafficManagementModule::clearNextHop(NodeNum dest)
{
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
if (!cache || dest == 0)
return;
concurrency::LockGuard guard(&cacheLock);
UnifiedCacheEntry *entry = findEntry(dest);
if (entry)
entry->next_hop = 0; // keep the entry (other stats), just drop the routing hint
#else
(void)dest;
#endif
}
void TrafficManagementModule::preloadNextHopsFromNodeDB()
{
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
if (!cache || !nodeDB)
return;
uint16_t seeded = 0;
concurrency::LockGuard guard(&cacheLock);
const size_t count = nodeDB->getNumMeshNodes();
for (size_t i = 0; i < count; i++) {
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!node || node->num == 0 || node->next_hop == 0)
continue;
bool isNew = false;
UnifiedCacheEntry *entry = findOrCreateEntry(node->num, &isNew);
// Don't clobber a freshly-learned confirmed hop with a (possibly stale) persisted one.
if (entry && entry->next_hop == 0) {
entry->next_hop = node->next_hop;
seeded++;
}
}
TM_LOG_INFO("Preloaded %u next-hop hints from NodeDB", static_cast<unsigned>(seeded));
#endif
}
// =============================================================================
// Epoch Management
// =============================================================================
@@ -830,6 +620,43 @@ void TrafficManagementModule::resetEpoch(uint32_t nowMs)
#endif
}
/**
* Sliding-epoch rebase — preserve cached state past the 8-bit timestamp horizon.
*
* Instead of flushing the whole cache when offsets approach overflow, advance the
* epoch by a fixed slab and shift every live entry's relative timestamps back by
* the same wall-clock amount. A valid entry's window is only a handful of ticks
* wide (TTL auto-scales with resolution), so live entries comfortably survive;
* already-expired entries clamp to 0 and are reclaimed by the maintenance sweep in
* the same locked pass. Reconstructed absolute time is preserved (minus a sub-tick
* remainder), so in-flight TTL checks remain correct across the rebase.
*
* Caller must hold cacheLock.
*/
void TrafficManagementModule::rebaseEpoch(uint32_t nowMs)
{
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
(void)nowMs;
// Slab stays well below the 200-tick reset threshold so a single rebase drops
// the offset back into range (~200 -> ~72 ticks) while live entries survive.
const uint32_t slabMs = 128UL * maxResolution() * 1000UL;
cacheEpochMs += slabMs;
TM_LOG_DEBUG("Rebasing cache epoch by %lus", static_cast<unsigned long>(slabMs / 1000UL));
for (uint16_t i = 0; i < cacheSize(); i++) {
if (cache[i].node == 0)
continue;
slideRelativeTime(cache[i].pos_time, slabMs, posTimeResolution);
slideRelativeTime(cache[i].rate_time, slabMs, rateTimeResolution);
slideRelativeTime(cache[i].unknown_time, slabMs, unknownTimeResolution);
}
#else
(void)nowMs;
#endif
}
// =============================================================================
// Position Hash (Compact Mode)
// =============================================================================
@@ -1042,11 +869,12 @@ int32_t TrafficManagementModule::runOnce()
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0
const uint32_t nowMs = millis();
// Check if epoch reset needed (~3.5 hours approaching 8-bit minute overflow)
if (needsEpochReset(nowMs)) {
concurrency::LockGuard guard(&cacheLock);
resetEpoch(nowMs);
return kMaintenanceIntervalMs;
// Warm-start the next-hop cache from persisted NodeInfoLite hints once nodeDB
// is populated. Done here (not in the constructor) so nodeDB has finished
// loading. Takes its own lock, so call before acquiring the sweep guard below.
if (!nextHopPreloaded) {
preloadNextHopsFromNodeDB();
nextHopPreloaded = true;
}
// Calculate TTLs for cache expiration
@@ -1065,6 +893,13 @@ int32_t TrafficManagementModule::runOnce()
const uint32_t sweepStartMs = millis();
concurrency::LockGuard guard(&cacheLock);
// Slide the epoch instead of flushing when offsets approach 8-bit overflow.
// Rebase preserves live entries; only already-expired ones clamp to 0 and are
// reclaimed by the sweep below in this same locked pass.
if (needsEpochReset(nowMs))
rebaseEpoch(nowMs);
for (uint16_t i = 0; i < cacheSize(); i++) {
if (cache[i].node == 0)
continue;
@@ -1104,6 +939,11 @@ int32_t TrafficManagementModule::runOnce()
}
}
// A confirmed next-hop hint has no TTL of its own and keeps the slot alive,
// so an aged-out routing hint outlives the dedup/rate/unknown state.
if (cache[i].next_hop != 0)
anyValid = true;
// If all data expired, free the slot entirely
if (!anyValid) {
memset(&cache[i], 0, sizeof(UnifiedCacheEntry));
@@ -1118,11 +958,9 @@ int32_t TrafficManagementModule::runOnce()
static_cast<unsigned long>(millis() - sweepStartMs));
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
if (nodeInfoPayload && nodeInfoIndex) {
TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u target (%u packed slots, %u buckets, %u-bit tags, %u-byte index)",
static_cast<unsigned>(countNodeInfoEntriesLocked()), static_cast<unsigned>(nodeInfoTargetEntries()),
static_cast<unsigned>(nodeInfoIndexSlots()), static_cast<unsigned>(nodeInfoBucketCount()),
static_cast<unsigned>(nodeInfoTagBits()), static_cast<unsigned>(nodeInfoIndexMetadataBudgetBytes()));
if (nodeInfoPayload) {
TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u", static_cast<unsigned>(countNodeInfoEntriesLocked()),
static_cast<unsigned>(nodeInfoTargetEntries()));
}
#endif
@@ -1153,8 +991,11 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p,
const int32_t lat_truncated = truncateLatLon(pos->latitude_i, precision);
const int32_t lon_truncated = truncateLatLon(pos->longitude_i, precision);
const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision);
const uint32_t minIntervalMs = secsToMs(Default::getConfiguredOrDefault(
moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs));
// Drop gate uses the RAW configured interval: 0 means "dedup disabled" (the
// contract documented below). The 12h default is only for resolution/TTL
// sizing (constructor / runOnce), not for deciding whether to drop — feeding
// the default here would silently turn the 0-disables-dedup contract off.
const uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs);
bool isNew = false;
concurrency::LockGuard guard(&cacheLock);
@@ -1218,7 +1059,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke
// If the PSRAM cache exists but misses, we intentionally do not fall back
// to the node-wide table. This keeps the PSRAM direct-reply path separate
// from NodeInfoModule/NodeDB behavior when PSRAM is available.
if (nodeInfoPayload && nodeInfoIndex) {
if (nodeInfoPayload) {
TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to);
return false;
}
@@ -1378,7 +1219,9 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p,
entry->unknown_count = 0;
}
// Increment counter (saturates at 255)
// Increment counter (saturates at 255). Same saturation handling as
// isRateLimited: without it, a clamped threshold of 255 can never fire.
const bool alreadySaturated = (entry->unknown_count == UINT8_MAX);
saturatingIncrement(entry->unknown_count);
// Check against threshold
@@ -1386,7 +1229,7 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p,
if (threshold > 255)
threshold = 255;
bool drop = entry->unknown_count > threshold;
bool drop = entry->unknown_count > threshold || (alreadySaturated && threshold == 255);
if (drop || entry->unknown_count == threshold) {
TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, entry->unknown_count, threshold,
drop ? "DROP" : "at-limit");
+80 -178
View File
@@ -20,9 +20,11 @@
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
*
* Memory Optimization:
* Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction
* compared to separate per-feature caches. Timestamps are stored as 8-bit relative
* offsets from a rolling epoch to further reduce memory footprint.
* Uses one flat unified cache (plain array, linear scan) shared by all
* per-node features instead of separate per-feature caches. Timestamps are
* stored as 8-bit relative offsets from a rolling epoch to further reduce
* memory footprint. LoRa packet rates are low enough that an O(n) scan of
* ~1000 11-byte entries is negligible next to packet processing.
*/
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
{
@@ -38,6 +40,22 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
void resetStats();
void recordRouterHopPreserved();
// Next-hop overflow cache (routing hint).
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
// byte must come from a bidirectionally-verified relay, not one-way inference.
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
void setNextHop(NodeNum dest, uint8_t nextHopByte);
uint8_t getNextHopHint(NodeNum dest);
void clearNextHop(NodeNum dest);
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
// nodeDB is populated (lazily on first maintenance pass).
void preloadNextHopsFromNodeDB();
/**
* Check if this packet should have its hops exhausted.
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
@@ -55,14 +73,18 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
int32_t runOnce() override;
// Protected so test shims can force epoch rollover behavior.
void resetEpoch(uint32_t nowMs);
// Sliding-epoch rebase: advance the epoch and shift live entries back by the
// same wall-clock amount instead of flushing, so cached state survives past the
// ~19h horizon. Caller must hold cacheLock.
void rebaseEpoch(uint32_t nowMs);
private:
// =========================================================================
// Unified Cache Entry (10 bytes) - Same for ALL platforms
// Unified Cache Entry (11 bytes) - Same for ALL platforms
// =========================================================================
//
// A single compact structure used across ESP32, NRF52, and all other platforms.
// Memory: 10 bytes × 2048 entries = 20KB
// Memory: 11 bytes × TRAFFIC_MANAGEMENT_CACHE_SIZE entries (default 1000 = 11KB)
//
// Position Fingerprinting:
// Instead of storing full coordinates (8 bytes) or a computed hash,
@@ -90,6 +112,16 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
// [7] pos_time - Position timestamp (1 byte, adaptive resolution)
// [8] rate_time - Rate window start (1 byte, adaptive resolution)
// [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution)
// [10] next_hop - Last-byte relay to reach `node` (1 byte, 0 = none)
//
// next_hop semantics:
// A routing hint: the last byte of the NodeNum to use as next hop to reach
// `node`. Written ONLY from NextHopRouter's ACK-confirmed decision (a
// bidirectionally-verified relay), never inferred one-way from relayed
// traffic. The TMM cache acts as an overflow store for confirmed next-hops
// that have aged out of the hot NodeDB (NodeInfoLite). Unlike the other
// fields it has no TTL of its own — it keeps its slot alive (see runOnce)
// and is refreshed only on the next confirmed exchange.
//
struct __attribute__((packed)) UnifiedCacheEntry {
NodeNum node; // 4 bytes - Node identifier (0 = empty slot)
@@ -99,66 +131,27 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution)
uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution)
uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution)
uint8_t next_hop; // 1 byte - Last-byte relay to reach `node` (0 = none). See note below.
};
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
static_assert(sizeof(UnifiedCacheEntry) == 11, "UnifiedCacheEntry should be 11 bytes");
// =========================================================================
// Cuckoo Hash Table Implementation
// Flat unified cache
// =========================================================================
//
// Cuckoo hashing provides O(1) worst-case lookup time using two hash functions.
// Each key can be in one of two possible locations (h1 or h2). On collision,
// the existing entry is "kicked" to its alternate location.
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
// most cacheSize() × 11 B — microseconds at LoRa packet rates, not worth a
// hash table. Insertion on a full cache evicts the stalest entry,
// preferring entries without a next_hop hint (those are the long-tail
// routing state this cache exists to keep).
//
// Benefits over linear scan:
// - O(1) lookup vs O(n) - critical at packet processing rates
// - O(1) insertion (amortized) with simple eviction on cycles
// - ~95% load factor achievable
//
// Cache size rounds to power-of-2 for fast modulo via bitmask.
// TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048
//
static constexpr uint16_t cacheSize();
static constexpr uint16_t cacheMask();
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
// Hash functions for cuckoo hashing
inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); }
inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); }
static constexpr uint8_t cuckooHashBits();
// NodeInfo cache configuration (PSRAM path):
// - Payload lives in PSRAM
// - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing
// (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty".
static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store
static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95;
static constexpr uint8_t kNodeInfoBucketSize = 4;
static constexpr uint8_t kNodeInfoTagBits = 12;
static constexpr uint16_t kNodeInfoTagMask = static_cast<uint16_t>((1u << kNodeInfoTagBits) - 1u);
static constexpr uint16_t kNodeInfoIndexSlotsRaw =
static_cast<uint16_t>((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits);
static constexpr uint16_t kNodeInfoIndexSlots =
static_cast<uint16_t>(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize));
static constexpr uint16_t kNodeInfoTargetEntries =
static_cast<uint16_t>((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u);
static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size");
static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index");
static constexpr uint16_t nodeInfoTargetEntries();
static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes();
static constexpr uint8_t nodeInfoTargetOccupancyPercent();
static constexpr uint8_t nodeInfoBucketSize();
static constexpr uint8_t nodeInfoTagBits();
static constexpr uint16_t nodeInfoTagMask();
static constexpr uint16_t nodeInfoIndexSlots();
static constexpr uint16_t nodeInfoBucketCount();
static constexpr uint16_t nodeInfoBucketMask();
static constexpr uint8_t nodeInfoBucketHashBits();
inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); }
inline uint16_t nodeInfoHash2(NodeNum node) const
{
return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask();
}
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
// =========================================================================
// Adaptive Timestamp Resolution
@@ -192,18 +185,28 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
return static_cast<uint16_t>(res);
}
// Convert to/from 8-bit relative timestamps with given resolution
// Convert to/from 8-bit relative timestamps with given resolution.
//
// All stored timestamps carry a uniform +1 "presence" offset: a value of 0 is
// reserved for "no timestamp recorded" (which is also the zero-initialized
// state), and stored values 1..255 encode raw ticks 0..254. This keeps the
// 0-means-empty sentinel consistent with memset/calloc zeroing across every
// sub-store, so the maintenance sweep's `_time != 0` presence checks are
// unambiguous (a timestamp recorded in the first tick after the epoch is no
// longer mistaken for an empty slot). The offset is applied here and removed
// on read, so it cancels out in all window math.
uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const
{
uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL);
return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast<uint8_t>(ticks);
return (ticks >= UINT8_MAX) ? UINT8_MAX : static_cast<uint8_t>(ticks + 1);
}
uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const
{
return cacheEpochMs + (static_cast<uint32_t>(ticks) * resolutionSecs * 1000UL);
return (ticks == 0) ? cacheEpochMs : cacheEpochMs + (static_cast<uint32_t>(ticks - 1) * resolutionSecs * 1000UL);
}
// Convenience wrappers for each timestamp type
// Convenience wrappers for each timestamp type (the +1 presence offset lives
// in the shared converters above, so these are plain pass-throughs).
uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); }
uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); }
@@ -213,17 +216,20 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); }
uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); }
// Epoch reset when any timestamp approaches overflow
// With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max)
bool needsEpochReset(uint32_t nowMs) const
// Coarsest of the per-feature resolutions (seconds per tick).
uint16_t maxResolution() const
{
uint16_t maxRes = posTimeResolution;
if (rateTimeResolution > maxRes)
maxRes = rateTimeResolution;
if (unknownTimeResolution > maxRes)
maxRes = unknownTimeResolution;
return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL);
return maxRes;
}
// True when relative offsets approach 8-bit overflow.
// With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max).
bool needsEpochReset(uint32_t nowMs) const { return (nowMs - cacheEpochMs) > (200UL * maxResolution() * 1000UL); }
// =========================================================================
// Position Fingerprint
// =========================================================================
@@ -246,7 +252,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
// =========================================================================
mutable concurrency::Lock cacheLock; // Protects all cache access
UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms)
UnifiedCacheEntry *cache = nullptr; // Flat unified cache (linear scan; all platforms)
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
struct NodeInfoPayloadEntry {
@@ -278,11 +284,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
uint8_t decodedBitfield;
};
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM
uint16_t nodeInfoAllocHint = 0;
uint16_t nodeInfoEvictCursor = 0;
meshtastic_TrafficManagementStats stats;
@@ -293,29 +296,22 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
NodeNum exhaustRequestedFrom = 0;
PacketId exhaustRequestedId = 0;
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
bool nextHopPreloaded = false;
// =========================================================================
// Cache Operations
// =========================================================================
// Find or create entry for node using cuckoo hashing
// Returns nullptr if cache is full and eviction fails
// Find or create entry for node (linear scan; stalest-first eviction when full)
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
// Find existing entry (no creation)
UnifiedCacheEntry *findEntry(NodeNum node);
// NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads)
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
uint16_t findNodeInfoPayloadIndex(NodeNum node) const;
bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex);
uint16_t allocateNodeInfoPayloadSlot();
uint16_t evictNodeInfoPayloadSlot();
bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag);
uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const;
uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const;
uint16_t getNodeInfoTag(uint16_t slot) const;
void setNodeInfoTag(uint16_t slot, uint16_t tag);
uint16_t countNodeInfoEntriesLocked() const;
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
@@ -333,101 +329,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
void incrementStat(uint32_t *field);
};
// =========================================================================
// Compile-time Cache Size Calculations
// =========================================================================
//
// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient
// cuckoo hash indexing (allows bitmask instead of modulo).
//
// These use C++11-compatible constexpr (single return statement).
//
namespace detail
{
// Helper: round up to next power of 2 using bit manipulation
constexpr uint16_t nextPow2(uint16_t n)
{
return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1);
}
// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr.
constexpr uint8_t log2Floor(uint16_t n)
{
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n >> 1)));
}
// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr.
constexpr uint8_t log2Ceil(uint16_t n)
{
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n - 1)));
}
} // namespace detail
constexpr uint16_t TrafficManagementModule::cacheSize()
{
return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE);
}
constexpr uint16_t TrafficManagementModule::cacheMask()
{
return cacheSize() > 0 ? cacheSize() - 1 : 0;
}
constexpr uint8_t TrafficManagementModule::cuckooHashBits()
{
return detail::log2Floor(cacheSize());
}
constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries()
{
return kNodeInfoTargetEntries;
}
constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes()
{
return kNodeInfoIndexMetadataBudgetBytes;
}
constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent()
{
return kNodeInfoTargetOccupancyPercent;
}
constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize()
{
return kNodeInfoBucketSize;
}
constexpr uint8_t TrafficManagementModule::nodeInfoTagBits()
{
return kNodeInfoTagBits;
}
constexpr uint16_t TrafficManagementModule::nodeInfoTagMask()
{
return kNodeInfoTagMask;
}
constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots()
{
return kNodeInfoIndexSlots;
}
constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount()
{
return static_cast<uint16_t>(nodeInfoIndexSlots() / nodeInfoBucketSize());
}
constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask()
{
return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0;
}
constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits()
{
return detail::log2Floor(nodeInfoBucketCount());
}
static_assert(TRAFFIC_MANAGEMENT_CACHE_SIZE <= UINT16_MAX, "cacheSize() returns uint16_t");
extern TrafficManagementModule *trafficManagementModule;
+465
View File
@@ -0,0 +1,465 @@
// Unit tests for NextHop direct-message reliability mitigations (see docs/nexthop-routing-reliability.md):
// M1 - NodeDB::resolveLastByte / resolveUniqueLastByte (ambiguity-aware last-byte resolution)
// M2 - NextHopRouter::getNextHop strict-neighbor gate + Router::shouldDecrementHopLimit favorite check
// M3 - NextHopRouter route-health freshness / failure decay
//
// Time handling: the route-health helpers take `now` as a parameter so the 30-minute TTL logic is
// pure and testable without a clock mock. getNextHop()/sinceLastSeen() use the real native clock;
// we back-date timestamps relative to it, and the unsigned-subtraction age math is rollover-safe.
#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc.
#include "TestUtil.h"
#include <unity.h>
#include "configuration.h"
#include "gps/RTC.h"
#include "mesh/NextHopRouter.h"
#include "mesh/NodeDB.h"
#include <cstdio>
#include <cstring>
#include <memory>
#define MSG_BUF_LEN 200
#define TEST_MSG_FMT(fmt, ...) \
do { \
char _buf[MSG_BUF_LEN]; \
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
TEST_MESSAGE(_buf); \
} while (0)
static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11
// ---------------------------------------------------------------------------
// MockNodeDB — inject nodes with controlled last byte, hop distance, age, role, favorite flag.
// ---------------------------------------------------------------------------
class MockNodeDB : public NodeDB
{
public:
void clearTestNodes()
{
testNodes.clear();
meshNodes = &testNodes;
numMeshNodes = 0;
}
// ageSecs is how long ago we last heard the node; getTime() returns a large Unix timestamp on
// native, so getTime()-ageSecs does not underflow for the ranges used here.
void addNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs,
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, bool favorite = false,
bool ignored = false, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE)
{
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
node.num = num;
node.has_hops_away = hasHops;
node.hops_away = hopsAway;
node.role = role;
node.next_hop = nextHop;
node.last_heard = getTime() - ageSecs;
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, favorite);
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_IGNORED_MASK, ignored);
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true);
testNodes.push_back(node);
meshNodes = &testNodes;
numMeshNodes = testNodes.size();
}
std::vector<meshtastic_NodeInfoLite> testNodes;
};
// ---------------------------------------------------------------------------
// Test shim — expose getNextHop and the route-health helpers; reset health between tests.
// Nulls cryptLock so the Router base can be (re)constructed (same pattern as test_mqtt MockRouter).
// ---------------------------------------------------------------------------
class NextHopRouterTestShim : public NextHopRouter
{
public:
NextHopRouterTestShim() : NextHopRouter()
{
delete cryptLock;
cryptLock = nullptr;
}
using NextHopRouter::clearRouteHealth;
using NextHopRouter::findRouteHealth;
using NextHopRouter::getNextHop;
using NextHopRouter::getOrAllocRouteHealth;
using NextHopRouter::isRouteStale;
using NextHopRouter::noteRouteFailure;
using NextHopRouter::noteRouteLearned;
using NextHopRouter::noteRouteSuccess;
using Router::shouldDecrementHopLimit; // protected in Router
void resetRouteHealthForTest()
{
for (auto &h : routeHealth)
h = RouteHealth{};
}
};
static MockNodeDB *mockNodeDB = nullptr;
static NextHopRouterTestShim *shim = nullptr;
static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC;
static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD;
static constexpr uint8_t HEALTH_MAX = NextHopRouter::ROUTE_HEALTH_MAX;
// Helper: a decoded packet whose hops-away is `hopsAway`, relayed by last byte `relay`.
static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.relay_node = relay;
p.hop_start = 4;
p.hop_limit = 4 - hopsAway; // getHopsAway() == hop_start - hop_limit
return p;
}
void setUp(void)
{
myNodeInfo.my_node_num = kLocalNode;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
mockNodeDB->clearTestNodes();
shim->resetRouteHealthForTest();
}
void tearDown(void) {}
// ===========================================================================
// Group 1 — resolveLastByte (M1)
// ===========================================================================
void test_resolve_none_when_empty(void)
{
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
TEST_ASSERT_EQUAL(LastByteResolution::None, r.status);
}
void test_resolve_zero_byte_is_none(void)
{
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel — never resolves.
mockNodeDB->addNode(0x22222200, 0, true, 60); // last byte maps to 0xFF, not 0
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, true).status);
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, false).status);
}
void test_resolve_unique_neighbor(void)
{
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct, fresh, last byte 0xAB
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
}
void test_resolve_collision_is_ambiguous(void)
{
// Birthday collision: two fresh direct neighbors share last byte 0xAB.
mockNodeDB->addNode(0x000005AB, 0, true, 60);
mockNodeDB->addNode(0x000006AB, 0, true, 60);
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, r.status);
TEST_ASSERT_EQUAL_HEX32(0, r.num); // never silently picks one
}
void test_resolve_strict_excludes_stale(void)
{
mockNodeDB->addNode(0x000005AB, 0, true, 60); // fresh
mockNodeDB->addNode(0x000006AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100); // stale
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
}
void test_resolve_strict_excludes_far(void)
{
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor
mockNodeDB->addNode(0x000006AB, 2, true, 60); // 2 hops away -> not a direct neighbor
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
}
void test_resolve_lenient_includes_favorite_router(void)
{
// Unknown hop distance, but a favorite ROUTER: lenient gate accepts, strict gate does not.
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xAB, false).status);
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
}
void test_resolve_lenient_collision_favorite_plus_neighbor(void)
{
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor, same byte
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xAB, false).status);
}
void test_resolve_skips_self(void)
{
// A node equal to us (last byte 0x11) must never resolve to ourselves.
mockNodeDB->addNode(kLocalNode, 0, true, 60);
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x11, true).status);
}
void test_resolve_skips_ignored(void)
{
mockNodeDB->addNode(0x000005AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, /*ignored=*/true);
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
}
void test_resolve_0x00_maps_to_0xFF_and_collides(void)
{
// getLastByteOfNodeNum() maps ...00 -> 0xFF, so a ...00 node and a ...FF node collide on 0xFF.
TEST_ASSERT_EQUAL_HEX8(0xFF, mockNodeDB->getLastByteOfNodeNum(0x11111100));
mockNodeDB->addNode(0x11111100, 0, true, 60); // last byte 0xFF (remapped)
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xFF, true).status);
mockNodeDB->addNode(0x222222FF, 0, true, 60); // genuine ...FF
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xFF, true).status);
}
void test_resolve_unique_helper(void)
{
mockNodeDB->addNode(0x000005AB, 0, true, 60);
NodeNum out = 0;
TEST_ASSERT_TRUE(mockNodeDB->resolveUniqueLastByte(0xAB, true, &out));
TEST_ASSERT_EQUAL_HEX32(0x000005AB, out);
mockNodeDB->addNode(0x000006AB, 0, true, 60); // create collision
TEST_ASSERT_FALSE(mockNodeDB->resolveUniqueLastByte(0xAB, true));
}
// ===========================================================================
// Group 2 — getNextHop (M2 send-path gate + M3 decay)
// ===========================================================================
static constexpr NodeNum DEST = 0x000000B0; // DM destination (last byte 0xB0, distinct from 0xAB)
void test_getnexthop_unique_returns_byte(void)
{
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
mockNodeDB->addNode(0x000005AB, 0, true, 60); // unique fresh neighbor with byte 0xAB
auto nh = shim->getNextHop(DEST, /*relay=*/0x11);
TEST_ASSERT_TRUE(nh.has_value());
TEST_ASSERT_EQUAL_HEX8(0xAB, nh.value());
}
void test_getnexthop_ambiguous_floods(void)
{
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
mockNodeDB->addNode(0x000005AB, 0, true, 60);
mockNodeDB->addNode(0x000006AB, 0, true, 60); // collision -> ambiguous
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
}
void test_getnexthop_vanished_neighbor_floods(void)
{
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
// The only 0xAB node is stale -> strict gate yields None -> flood.
mockNodeDB->addNode(0x000005AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100);
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
}
void test_getnexthop_split_horizon_floods(void)
{
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
mockNodeDB->addNode(0x000005AB, 0, true, 60);
// relay_node == stored next_hop -> don't send it back the way it came.
TEST_ASSERT_FALSE(shim->getNextHop(DEST, /*relay=*/0xAB).has_value());
}
void test_getnexthop_broadcast_is_nullopt(void)
{
TEST_ASSERT_FALSE(shim->getNextHop(NODENUM_BROADCAST, 0x11).has_value());
}
void test_getnexthop_decays_stale_route(void)
{
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
mockNodeDB->addNode(0x000005AB, 0, true, 60); // a valid unique neighbor exists...
// ...but the health record is older than the TTL, so the route should decay to flooding.
shim->noteRouteLearned(DEST, 0xAB, millis() - (TTL + 5000));
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
// Decay also clears the persisted next_hop and the RAM health record.
TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockNodeDB->getMeshNode(DEST)->next_hop);
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
}
// ===========================================================================
// Group 3 — route-health helpers (M3)
// ===========================================================================
void test_health_fresh_not_stale(void)
{
shim->noteRouteLearned(DEST, 0xAB, 1000);
RouteHealth *h = shim->findRouteHealth(DEST);
TEST_ASSERT_NOT_NULL(h);
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 1000 + TTL - 1));
}
void test_health_ttl_expiry(void)
{
shim->noteRouteLearned(DEST, 0xAB, 1000);
RouteHealth *h = shim->findRouteHealth(DEST);
TEST_ASSERT_TRUE(shim->isRouteStale(*h, 1000 + TTL)); // boundary is inclusive (>=)
}
void test_health_ttl_rollover_safe(void)
{
const uint32_t learnAt = 0xFFFFFFFFu - 1000; // learned just before the millis() rollover
shim->noteRouteLearned(DEST, 0xAB, learnAt);
RouteHealth *h = shim->findRouteHealth(DEST);
// 1500 ms later (wrapped to now=500): unsigned subtraction yields ~1500 ms, not "stale".
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 500));
}
void test_health_failure_threshold(void)
{
shim->noteRouteLearned(DEST, 0xAB, 1000);
for (uint8_t i = 1; i < THRESH; i++)
shim->noteRouteFailure(DEST);
TEST_ASSERT_FALSE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH-1 failures: ok
shim->noteRouteFailure(DEST);
TEST_ASSERT_TRUE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH failures: stale
}
void test_health_success_resets_failures(void)
{
shim->noteRouteLearned(DEST, 0xAB, 1000);
shim->noteRouteFailure(DEST);
shim->noteRouteFailure(DEST);
shim->noteRouteSuccess(DEST, 2000);
RouteHealth *h = shim->findRouteHealth(DEST);
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 2000));
}
void test_health_relearn_same_hop_keeps_failures(void)
{
// Anti-flap: an asymmetric reverse path re-teaching the same dead hop must not reset failures.
shim->noteRouteLearned(DEST, 0xAB, 1000);
shim->noteRouteFailure(DEST);
shim->noteRouteFailure(DEST);
shim->noteRouteLearned(DEST, 0xAB, 2000); // same hop
TEST_ASSERT_EQUAL_UINT8(2, shim->findRouteHealth(DEST)->consecutiveFailures);
}
void test_health_relearn_new_hop_resets_failures(void)
{
shim->noteRouteLearned(DEST, 0xAB, 1000);
shim->noteRouteFailure(DEST);
shim->noteRouteFailure(DEST);
shim->noteRouteLearned(DEST, 0xCD, 2000); // genuinely new hop -> clean slate
RouteHealth *h = shim->findRouteHealth(DEST);
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
TEST_ASSERT_EQUAL_HEX8(0xCD, h->lastNextHop);
}
void test_health_failure_without_record_is_noop(void)
{
shim->noteRouteFailure(DEST); // no record yet
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
}
void test_health_clear(void)
{
shim->noteRouteLearned(DEST, 0xAB, 1000);
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(DEST));
shim->clearRouteHealth(DEST);
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
}
void test_health_lru_eviction_bounds_table(void)
{
// Fill every slot with increasing learn times, then add one more: the oldest must be evicted.
for (uint8_t i = 0; i < HEALTH_MAX; i++)
shim->noteRouteLearned(0x1000 + i, 0xAB, 1000 + (uint32_t)i * 1000);
NodeNum oldest = 0x1000;
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(oldest));
shim->noteRouteLearned(0x2000, 0xAB, 1000 + (uint32_t)HEALTH_MAX * 1000); // overflow
TEST_ASSERT_NULL(shim->findRouteHealth(oldest)); // evicted
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(0x2000)); // newest present
}
// ===========================================================================
// Group 4 — shouldDecrementHopLimit favorite-router resolution (M2, site 4)
// ===========================================================================
void test_hoplimit_preserve_unique_favorite_router(void)
{
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
meshtastic_MeshPacket p = makeRelayedPacket(/*relay=*/0xAB, /*hopsAway=*/1);
TEST_ASSERT_FALSE(shim->shouldDecrementHopLimit(&p)); // preserve
}
void test_hoplimit_decrement_on_colliding_favorites(void)
{
// Headline regression: two favorite routers share the relay byte -> ambiguous -> decrement
// (the old "first NodeDB match wins" scan would non-deterministically preserve).
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
mockNodeDB->addNode(0x000008AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // decrement
}
void test_hoplimit_decrement_when_resolved_not_favorite(void)
{
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/false);
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // unique but not a favorite -> decrement
}
// ===========================================================================
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
mockNodeDB = new MockNodeDB();
shim = new NextHopRouterTestShim();
nodeDB = mockNodeDB;
printf("\n=== resolveLastByte (M1) ===\n");
RUN_TEST(test_resolve_none_when_empty);
RUN_TEST(test_resolve_zero_byte_is_none);
RUN_TEST(test_resolve_unique_neighbor);
RUN_TEST(test_resolve_collision_is_ambiguous);
RUN_TEST(test_resolve_strict_excludes_stale);
RUN_TEST(test_resolve_strict_excludes_far);
RUN_TEST(test_resolve_lenient_includes_favorite_router);
RUN_TEST(test_resolve_lenient_collision_favorite_plus_neighbor);
RUN_TEST(test_resolve_skips_self);
RUN_TEST(test_resolve_skips_ignored);
RUN_TEST(test_resolve_0x00_maps_to_0xFF_and_collides);
RUN_TEST(test_resolve_unique_helper);
printf("\n=== getNextHop (M2 + M3 decay) ===\n");
RUN_TEST(test_getnexthop_unique_returns_byte);
RUN_TEST(test_getnexthop_ambiguous_floods);
RUN_TEST(test_getnexthop_vanished_neighbor_floods);
RUN_TEST(test_getnexthop_split_horizon_floods);
RUN_TEST(test_getnexthop_broadcast_is_nullopt);
RUN_TEST(test_getnexthop_decays_stale_route);
printf("\n=== route-health helpers (M3) ===\n");
RUN_TEST(test_health_fresh_not_stale);
RUN_TEST(test_health_ttl_expiry);
RUN_TEST(test_health_ttl_rollover_safe);
RUN_TEST(test_health_failure_threshold);
RUN_TEST(test_health_success_resets_failures);
RUN_TEST(test_health_relearn_same_hop_keeps_failures);
RUN_TEST(test_health_relearn_new_hop_resets_failures);
RUN_TEST(test_health_failure_without_record_is_noop);
RUN_TEST(test_health_clear);
RUN_TEST(test_health_lru_eviction_bounds_table);
printf("\n=== shouldDecrementHopLimit (M2 site 4) ===\n");
RUN_TEST(test_hoplimit_preserve_unique_favorite_router);
RUN_TEST(test_hoplimit_decrement_on_colliding_favorites);
RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite);
exit(UNITY_END());
}
void loop() {}
+35 -1
View File
@@ -26,17 +26,22 @@ class NodeDBTestShim : public NodeDB
void runDemote() { demoteOldestHotNodesToWarm(); }
void runCleanup() { cleanupMeshDB(); }
// Read back the role + protected category the warm tier cached for a node.
bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); }
void clearHot()
{
meshNodes->clear();
numMeshNodes = 0;
}
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey)
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey,
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT)
{
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
n.num = num;
n.last_heard = lastHeard;
n.role = role;
if (favorite)
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
if (ignored)
@@ -99,6 +104,34 @@ static void test_migration_demotesOldestKeepsKeepersAndSelf(void)
TEST_ASSERT_TRUE(warmHasKey(2000 + 3)); // ...but its key kept in the warm tier
}
// Eviction carries the device role + protected category into the warm tier. A TRACKER is
// hop-protected but NOT eviction-protected, so it gets demoted with its key; the warm
// record must report role=TRACKER / category=Role. A plain CLIENT carries role=CLIENT/None.
static void test_migration_carriesRoleAndProtectedIntoWarm(void)
{
db->seedSelf();
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
for (int i = 1; i <= extra; i++) {
const auto role = (i == 3) ? meshtastic_Config_DeviceConfig_Role_TRACKER : meshtastic_Config_DeviceConfig_Role_CLIENT;
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true,
/*withKey=*/true, role);
}
db->runDemote();
uint8_t role = 0xFF, prot = 0xFF;
// TRACKER (i=3): demoted out of hot, key kept, role + protected carried into warm.
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3));
TEST_ASSERT_TRUE(warmHasKey(2000 + 3));
TEST_ASSERT_TRUE(db->warmMeta(2000 + 3, role, prot));
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, role);
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
// CLIENT (i=4): also demoted, carries role=CLIENT / category=None.
TEST_ASSERT_TRUE(db->warmMeta(2000 + 4, role, prot));
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_CLIENT, role);
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
}
// Favourite handling: a favourite is never the eviction victim, even when it is
// the oldest node in a full hot store.
static void test_eviction_preservesFavorite(void)
@@ -172,6 +205,7 @@ NDB_TEST_ENTRY void setup()
UNITY_BEGIN();
RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf);
RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm);
RUN_TEST(test_eviction_preservesFavorite);
RUN_TEST(test_ignored_survivesEvictionAndCleanup);
RUN_TEST(test_protectedCap_refusesBeyondLimit);
+198 -61
View File
@@ -1,3 +1,4 @@
#include "MeshTypes.h" // Include BEFORE TestUtil.h — provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h)
#include "TestUtil.h"
#include <cstdlib>
#include <unity.h>
@@ -10,6 +11,7 @@
#if HAS_TRAFFIC_MANAGEMENT
#include "airtime.h"
#include "mesh/CryptoEngine.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
@@ -28,6 +30,25 @@ constexpr NodeNum kLocalNode = 0x11111111;
constexpr NodeNum kRemoteNode = 0x22222222;
constexpr NodeNum kTargetNode = 0x33333333;
// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks
// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global
// airTime reporting 100% channel utilization for the enclosing scope.
class ScopedBusyAirTime
{
public:
ScopedBusyAirTime() : previous(airTime)
{
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++)
busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period
airTime = &busy;
}
~ScopedBusyAirTime() { airTime = previous; }
private:
AirTime busy;
AirTime *previous;
};
class MockNodeDB : public NodeDB
{
public:
@@ -54,6 +75,32 @@ class MockNodeDB : public NodeDB
cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
}
// Role the TMM should see for the cached node (sender-role-aware throttles).
void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; }
// Seed a node into the hot-store buffer at index 1 (index 0 is reserved for
// "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of
// MAX_NUM_NODES slots with `numMeshNodes` as the logical count — we grow the
// buffer if needed and bump the count, never clear()/push_back() (which would
// shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill).
void setHotNode(NodeNum n, uint8_t nextHop)
{
if (meshNodes->size() < 2)
meshNodes->resize(2);
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
(*meshNodes)[1].num = n;
(*meshNodes)[1].next_hop = nextHop;
numMeshNodes = 2;
}
// Evict everything but "self" — simulates the hot DB rolling over. Logical
// count only; the buffer is left intact so the invariant holds.
void rollHotStore()
{
numMeshNodes = 1;
clearCachedNode();
}
private:
bool hasCachedNode = false;
NodeNum cachedNodeNum = 0;
@@ -121,6 +168,9 @@ static void resetTrafficConfig()
config = meshtastic_LocalConfig_init_zero;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
channelFile = meshtastic_ChannelFile_init_zero;
owner.is_licensed = false;
myNodeInfo.my_node_num = kLocalNode;
router = nullptr;
@@ -175,6 +225,42 @@ static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32
return packet;
}
static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST);
meshtastic_Position pos = meshtastic_Position_init_zero;
pos.has_latitude_i = true;
pos.has_longitude_i = true;
pos.latitude_i = lat;
pos.longitude_i = lon;
pos.precision_bits = precisionBits;
packet.decoded.payload.size =
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
return packet;
}
static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out)
{
out = meshtastic_Position_init_zero;
return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out);
}
// Primary channel with a well-known single-byte PSK and the (empty -> preset)
// default name, so Channels::isWellKnownChannel(0) is true.
static void installWellKnownPrimaryChannel()
{
channelFile = meshtastic_ChannelFile_init_zero;
channelFile.channels_count = 1;
channelFile.channels[0].index = 0;
channelFile.channels[0].has_settings = true;
channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY;
channelFile.channels[0].settings.psk.size = 1;
channelFile.channels[0].settings.psk.bytes[0] = 1;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
}
static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName)
{
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
@@ -290,7 +376,7 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 3;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
@@ -305,31 +391,6 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
}
/**
* Verify routing/admin traffic is exempt from rate limiting.
* Important because throttling control traffic can destabilize the mesh.
*/
static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void)
{
moduleConfig.traffic_management.rate_limit_enabled = true;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode);
meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode);
for (int i = 0; i < 4; i++) {
ProcessMessage rr = module.handleReceived(routingPacket);
ProcessMessage ar = module.handleReceived(adminPacket);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(rr));
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(ar));
}
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
}
/**
* Verify packets sourced from this node bypass dedup and rate limiting.
* Important so local transmissions are not accidentally self-throttled.
@@ -650,12 +711,15 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi
#endif
/**
* Verify relayed telemetry broadcasts are hop-exhausted when enabled.
* Verify relayed telemetry broadcasts are hop-exhausted when enabled AND the
* channel is congested (telemetry exhaustion is gated on channel utilization,
* unlike position exhaustion).
* Important to prevent further mesh propagation while still allowing one relay step.
*/
static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
packet.hop_start = 5;
@@ -677,6 +741,7 @@ static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void)
static void test_tm_alterReceived_skipsLocalAndUnicast(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // congestion satisfied, so only the skip conditions are under test
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode);
@@ -794,8 +859,11 @@ static void test_tm_positionDedup_precision32_allowsDistinctPositions(void)
}
/**
* Verify invalid precision=0 is treated as full precision.
* Important so invalid config does not collapse all positions into one fingerprint.
* Verify precision=0 falls back to the default precision (same contract as
* >32: getConfiguredOrDefault + sanitizePositionPrecision treat 0 as unset).
* Important so invalid config does not collapse all positions into one
* fingerprint positions in different default-precision grid cells must
* still be distinct.
*/
static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void)
{
@@ -805,7 +873,7 @@ static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void)
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677);
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(second);
@@ -857,11 +925,11 @@ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero
moduleConfig.traffic_management.rate_limit_max_packets = 10;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
ProcessMessage seeded = module.handleReceived(text);
ProcessMessage seeded = module.handleReceived(telemetry);
ProcessMessage r1 = module.handleReceived(first);
ProcessMessage r2 = module.handleReceived(duplicate);
meshtastic_TrafficManagementStats stats = module.getStats();
@@ -882,7 +950,7 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void)
moduleConfig.traffic_management.rate_limit_window_secs = 1;
moduleConfig.traffic_management.rate_limit_max_packets = 1;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
ProcessMessage r1 = module.handleReceived(packet);
ProcessMessage r2 = module.handleReceived(packet);
@@ -895,30 +963,6 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void)
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
}
/**
* Verify rate-limit thresholds above 255 effectively clamp to 255.
* Important because counters are uint8_t and must not overflow behavior.
*/
static void test_tm_rateLimit_thresholdAbove255_clamps(void)
{
moduleConfig.traffic_management.rate_limit_enabled = true;
moduleConfig.traffic_management.rate_limit_window_secs = 60;
moduleConfig.traffic_management.rate_limit_max_packets = 300;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
for (int i = 0; i < 255; i++) {
ProcessMessage result = module.handleReceived(packet);
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
}
ProcessMessage dropped = module.handleReceived(packet);
meshtastic_TrafficManagementStats stats = module.getStats();
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(dropped));
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
}
/**
* Verify unknown-packet tracking resets after its active window expires.
* Important so old unknown traffic does not trigger delayed drops.
@@ -966,12 +1010,14 @@ static void test_tm_unknownPackets_thresholdAbove255_clamps(void)
}
/**
* Verify relayed position broadcasts can also be hop-exhausted.
* Verify relayed position broadcasts can also be hop-exhausted under the
* same pressure gate as telemetry (here: channel congestion).
* Important because telemetry and position use separate exhaust flags.
*/
static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
{
moduleConfig.traffic_management.exhaust_hop_position = true;
ScopedBusyAirTime busyChannel;
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST);
packet.hop_start = 5;
@@ -985,7 +1031,6 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
TEST_ASSERT_TRUE(module.shouldExhaustHops(packet));
TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets);
}
/**
* Verify hop exhaustion ignores undecoded/encrypted packets.
* Important so we never mutate packets that were not decoded by this module.
@@ -993,6 +1038,7 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
static void test_tm_alterReceived_skipsUndecodedPackets(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // congestion satisfied, so only the undecoded skip is under test
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST);
packet.hop_start = 5;
@@ -1014,6 +1060,7 @@ static void test_tm_alterReceived_skipsUndecodedPackets(void)
static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
@@ -1039,6 +1086,7 @@ static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void)
static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void)
{
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion
TrafficManagementModuleTestShim module;
meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
@@ -1083,6 +1131,93 @@ static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void)
TEST_ASSERT_EQUAL_INT32(60 * 1000, interval);
}
// ---------------------------------------------------------------------------
// Next-hop overflow cache
// ---------------------------------------------------------------------------
/**
* Round-trip set/get of a confirmed next hop, plus the input guards.
*/
static void test_tm_nextHop_setAndGetRoundTrip(void)
{
TrafficManagementModuleTestShim module;
// Unknown node yields no hint.
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
// Store a confirmed hop and read it back.
module.setNextHop(kTargetNode, 0x42);
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
// Zero dest and zero byte are rejected (no spurious entry created).
module.setNextHop(0, 0x42);
module.setNextHop(kRemoteNode, 0);
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kRemoteNode));
// Last-write-wins on re-confirmation.
module.setNextHop(kTargetNode, 0x99);
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
}
/**
* The headline scenario: a node carrying a next hop in the hot NodeInfoLite DB
* is warm-loaded into the TMM cache, then the hot DB is "rolled" (the node ages
* out entirely). The hint must still be served now exclusively from TMM.
*/
static void test_tm_nextHop_servedAfterNodeDbRoll(void)
{
TrafficManagementModuleTestShim module;
// Seed the hot NodeInfoLite DB with a node that has a confirmed next hop.
mockNodeDB->setHotNode(kTargetNode, 0x42);
// Warm-start the overflow cache from the hot DB.
module.preloadNextHopsFromNodeDB();
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
// Roll the main NodeInfoLite DB: the node is evicted from the hot store.
mockNodeDB->rollHotStore();
TEST_ASSERT_NULL(nodeDB->getMeshNode(kTargetNode)); // gone from the hot store
// Hit is still served — proving it now comes from the TMM overflow cache.
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
}
/**
* Preload must not clobber a freshly-learned (confirmed) hop with a possibly
* stale persisted one from NodeInfoLite.
*/
static void test_tm_nextHop_preloadDoesNotClobberLearned(void)
{
TrafficManagementModuleTestShim module;
// A fresher confirmed hop is already cached.
module.setNextHop(kTargetNode, 0x99);
// The hot DB carries an older next hop for the same node.
mockNodeDB->setHotNode(kTargetNode, 0x42);
module.preloadNextHopsFromNodeDB();
// The freshly-learned hop survives.
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
}
/**
* A pure routing hint (no dedup/rate/unknown state) must survive the maintenance
* sweep next_hop != 0 keeps the slot alive even though it has no TTL.
*/
static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void)
{
TrafficManagementModuleTestShim module;
module.setNextHop(kTargetNode, 0x42);
// The sweep frees slots whose sub-stores are all empty; next_hop must veto that.
module.runOnce();
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
}
} // namespace
void setUp(void)
@@ -1106,7 +1241,6 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow);
RUN_TEST(test_tm_positionDedup_allowsMovedPosition);
RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold);
RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts);
RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters);
RUN_TEST(test_tm_localDestination_bypassesTransitFilters);
RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops);
@@ -1130,7 +1264,6 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset);
RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero);
RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires);
RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps);
RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires);
RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps);
RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast);
@@ -1139,6 +1272,10 @@ TM_TEST_ENTRY void setup()
RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped);
RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval);
RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval);
RUN_TEST(test_tm_nextHop_setAndGetRoundTrip);
RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll);
RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned);
RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep);
exit(UNITY_END());
}
+88 -4
View File
@@ -13,8 +13,10 @@
#if WARM_NODE_COUNT > 0
#include "FSCommon.h"
#include "mesh/WarmNodeStore.h"
#include <cstring>
#include <vector>
namespace
{
@@ -76,12 +78,15 @@ void test_ws_take_removesEntry()
WarmNodeStore ws;
uint8_t key[32];
makeKey(key, 3);
ws.absorb(0x400, 1234, key);
ws.absorb(0x400, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
WarmNodeEntry e;
TEST_ASSERT_TRUE(ws.take(0x400, e));
TEST_ASSERT_EQUAL(0x400, e.num);
TEST_ASSERT_EQUAL(1234, e.last_heard);
// last_heard is quantised to the metadata quantum; role/protected ride the low bits.
TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e));
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
TEST_ASSERT_EQUAL_MEMORY(key, e.public_key, 32);
TEST_ASSERT_FALSE(ws.contains(0x400));
TEST_ASSERT_FALSE(ws.take(0x400, e));
@@ -111,13 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst()
// Fill with keyed entries except two keyless ones in the middle
for (size_t i = 0; i < ws.capacity(); i++) {
const bool keyless = (i == 5 || i == 10);
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, keyless ? (i == 10 ? 50 : 60) : 10, keyless ? NULL : key));
// Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives
// quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10).
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key));
}
// Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50),
// even though every keyed entry is older (ts=10)
uint8_t k2[32];
makeKey(k2, 0x43);
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70, k2));
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2));
TEST_ASSERT_FALSE(ws.contains(0x1000 + 10));
TEST_ASSERT_TRUE(ws.contains(0x1000 + 5));
TEST_ASSERT_TRUE(ws.contains(0x8888));
@@ -139,6 +146,31 @@ void test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless()
TEST_ASSERT_EQUAL(ws.capacity(), ws.count());
}
void test_ws_meta_roundTrip()
{
WarmNodeStore ws;
uint8_t key[32];
makeKey(key, 0x77);
// Keyed TRACKER(5)/Role-protected and keyless SENSOR(6)/unprotected.
TEST_ASSERT_TRUE(ws.absorb(0x700, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role));
TEST_ASSERT_TRUE(ws.absorb(0x701, 5678, NULL, 6 /* SENSOR */, (uint8_t)WarmProtected::None));
uint8_t role = 0xFF, prot = 0xFF;
TEST_ASSERT_TRUE(ws.lookupMeta(0x700, role, prot));
TEST_ASSERT_EQUAL(5, role);
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
TEST_ASSERT_TRUE(ws.lookupMeta(0x701, role, prot));
TEST_ASSERT_EQUAL(6, role);
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
// Absent node yields false and leaves outputs untouched-by-contract (just check return).
TEST_ASSERT_FALSE(ws.lookupMeta(0x999, role, prot));
// Default args still compile (role/protected = 0 = CLIENT/None).
TEST_ASSERT_TRUE(ws.absorb(0x702, 9999, NULL));
TEST_ASSERT_TRUE(ws.lookupMeta(0x702, role, prot));
TEST_ASSERT_EQUAL(0, role);
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
}
void test_ws_remove_and_clear()
{
WarmNodeStore ws;
@@ -175,6 +207,56 @@ void test_ws_persistence_roundTrip()
b.saveIfDirty();
}
// Migration: a v1 (WRM1) warm.dat must keep identity + key but discard last_heard
// (so its low bits aren't misread as role/protected). File backend only.
void test_ws_v1_migration_discardsLastHeard()
{
WarmNodeStore a;
uint8_t key[32], got[32];
makeKey(key, 0x66);
a.absorb(0x900, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
if (!a.saveIfDirty()) {
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
return;
}
// Read the whole v2 file, flip the 4-byte header magic to v1 ("WRM1"), write it back.
// (CRC covers only the entry bytes, so patching the header magic keeps it valid.)
std::vector<uint8_t> buf;
{
auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ);
if (!f) {
TEST_IGNORE_MESSAGE("warm.dat not readable in this environment");
return;
}
buf.resize(f.size());
f.read(buf.data(), buf.size());
f.close();
}
TEST_ASSERT_TRUE(buf.size() >= 4);
const uint32_t v1magic = 0x314D5257u; // "WRM1"
memcpy(buf.data(), &v1magic, sizeof(v1magic));
{
auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE);
TEST_ASSERT_TRUE((bool)f);
f.write(buf.data(), buf.size());
f.close();
}
WarmNodeStore b;
b.load();
TEST_ASSERT_TRUE(b.contains(0x900)); // identity survived migration
TEST_ASSERT_TRUE(b.copyKey(0x900, got)); // public key survived
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
uint8_t role = 0xFF, prot = 0xFF;
TEST_ASSERT_TRUE(b.lookupMeta(0x900, role, prot));
TEST_ASSERT_EQUAL(0, role); // last_heard discarded → role/protected reset
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
b.clear();
b.saveIfDirty();
}
WS_TEST_ENTRY void setup()
{
initializeTestEnvironment();
@@ -187,8 +269,10 @@ WS_TEST_ENTRY void setup()
RUN_TEST(test_ws_keylessCandidate_neverEvictsKeyedEntries);
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst);
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless);
RUN_TEST(test_ws_meta_roundTrip);
RUN_TEST(test_ws_remove_and_clear);
RUN_TEST(test_ws_persistence_roundTrip);
RUN_TEST(test_ws_v1_migration_discardsLastHeard);
exit(UNITY_END());
}