mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-20 02:11:28 +10:00
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:
co-authored by
Claude Sonnet 4.6
Ben Meadors
parent
ca7d82629d
commit
22072c5f4b
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+203
-14
@@ -98,21 +98,38 @@ 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
|
||||
// Single lookup for both relayer checks on the same (request_id, to) pair
|
||||
bool wasAlreadyRelayer = false;
|
||||
bool weWereSoleRelayer = false;
|
||||
bool weWereRelayer = false;
|
||||
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
|
||||
// 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. 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;
|
||||
bool weWereRelayer = false;
|
||||
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
|
||||
&weWereSoleRelayer);
|
||||
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
|
||||
// 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);
|
||||
return 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,9 +392,32 @@ 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
|
||||
@@ -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{};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+14
-27
@@ -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,
|
||||
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);
|
||||
// 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)) {
|
||||
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
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
nodeInfoPayload = static_cast<NodeInfoPayloadEntry *>(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry)));
|
||||
if (nodeInfoPayload) {
|
||||
nodeInfoPayloadFromPsram = true;
|
||||
TM_LOG_INFO("NodeInfo PSRAM cache ready");
|
||||
} else {
|
||||
nodeInfoPayload = static_cast<NodeInfoPayloadEntry *>(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry)));
|
||||
if (nodeInfoPayload) {
|
||||
nodeInfoPayloadFromPsram = true;
|
||||
TM_LOG_INFO("NodeInfo bucketed cuckoo cache ready");
|
||||
} else {
|
||||
TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB");
|
||||
free(nodeInfoIndex);
|
||||
nodeInfoIndex = nullptr;
|
||||
}
|
||||
TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB");
|
||||
}
|
||||
#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 (isNew)
|
||||
*isNew = false;
|
||||
if (!cache || node == 0)
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
UnifiedCacheEntry *empty = nullptr;
|
||||
UnifiedCacheEntry *victim = nullptr;
|
||||
bool victimHasHop = true;
|
||||
uint8_t victimRecency = UINT8_MAX;
|
||||
|
||||
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;
|
||||
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];
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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[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())
|
||||
return nullptr;
|
||||
|
||||
return &nodeInfoPayload[payloadIndex];
|
||||
for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) {
|
||||
if (nodeInfoPayload[i].node == node)
|
||||
return &nodeInfoPayload[i];
|
||||
}
|
||||
return nullptr;
|
||||
#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();
|
||||
|
||||
uint16_t payloadIndex = allocateNodeInfoPayloadSlot();
|
||||
if (payloadIndex == UINT16_MAX) {
|
||||
payloadIndex = evictNodeInfoPayloadSlot();
|
||||
if (payloadIndex == UINT16_MAX)
|
||||
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;
|
||||
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 (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));
|
||||
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 finalIndex = findNodeInfoPayloadIndex(node);
|
||||
if (finalIndex >= nodeInfoTargetEntries()) {
|
||||
// New entry did not survive insertion chain.
|
||||
if (payloadIndex < nodeInfoTargetEntries() && nodeInfoPayload[payloadIndex].node == node)
|
||||
nodeInfoPayload[payloadIndex].node = 0;
|
||||
NodeInfoPayloadEntry *slot = empty ? empty : lru;
|
||||
if (!slot)
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user