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
GitHub
Claude Opus 4.8
Ben Meadors
parent
ca7d82629d
commit
22072c5f4b
@@ -0,0 +1,465 @@
|
||||
// Unit tests for NextHop direct-message reliability mitigations (see docs/nexthop-routing-reliability.md):
|
||||
// M1 - NodeDB::resolveLastByte / resolveUniqueLastByte (ambiguity-aware last-byte resolution)
|
||||
// M2 - NextHopRouter::getNextHop strict-neighbor gate + Router::shouldDecrementHopLimit favorite check
|
||||
// M3 - NextHopRouter route-health freshness / failure decay
|
||||
//
|
||||
// Time handling: the route-health helpers take `now` as a parameter so the 30-minute TTL logic is
|
||||
// pure and testable without a clock mock. getNextHop()/sinceLastSeen() use the real native clock;
|
||||
// we back-date timestamps relative to it, and the unsigned-subtraction age math is rollover-safe.
|
||||
|
||||
#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc.
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#include "configuration.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "mesh/NextHopRouter.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#define MSG_BUF_LEN 200
|
||||
#define TEST_MSG_FMT(fmt, ...) \
|
||||
do { \
|
||||
char _buf[MSG_BUF_LEN]; \
|
||||
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
|
||||
TEST_MESSAGE(_buf); \
|
||||
} while (0)
|
||||
|
||||
static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB — inject nodes with controlled last byte, hop distance, age, role, favorite flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
// ageSecs is how long ago we last heard the node; getTime() returns a large Unix timestamp on
|
||||
// native, so getTime()-ageSecs does not underflow for the ranges used here.
|
||||
void addNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs,
|
||||
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, bool favorite = false,
|
||||
bool ignored = false, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
node.has_hops_away = hasHops;
|
||||
node.hops_away = hopsAway;
|
||||
node.role = role;
|
||||
node.next_hop = nextHop;
|
||||
node.last_heard = getTime() - ageSecs;
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, favorite);
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_IGNORED_MASK, ignored);
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test shim — expose getNextHop and the route-health helpers; reset health between tests.
|
||||
// Nulls cryptLock so the Router base can be (re)constructed (same pattern as test_mqtt MockRouter).
|
||||
// ---------------------------------------------------------------------------
|
||||
class NextHopRouterTestShim : public NextHopRouter
|
||||
{
|
||||
public:
|
||||
NextHopRouterTestShim() : NextHopRouter()
|
||||
{
|
||||
delete cryptLock;
|
||||
cryptLock = nullptr;
|
||||
}
|
||||
|
||||
using NextHopRouter::clearRouteHealth;
|
||||
using NextHopRouter::findRouteHealth;
|
||||
using NextHopRouter::getNextHop;
|
||||
using NextHopRouter::getOrAllocRouteHealth;
|
||||
using NextHopRouter::isRouteStale;
|
||||
using NextHopRouter::noteRouteFailure;
|
||||
using NextHopRouter::noteRouteLearned;
|
||||
using NextHopRouter::noteRouteSuccess;
|
||||
using Router::shouldDecrementHopLimit; // protected in Router
|
||||
|
||||
void resetRouteHealthForTest()
|
||||
{
|
||||
for (auto &h : routeHealth)
|
||||
h = RouteHealth{};
|
||||
}
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
static NextHopRouterTestShim *shim = nullptr;
|
||||
|
||||
static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC;
|
||||
static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD;
|
||||
static constexpr uint8_t HEALTH_MAX = NextHopRouter::ROUTE_HEALTH_MAX;
|
||||
|
||||
// Helper: a decoded packet whose hops-away is `hopsAway`, relayed by last byte `relay`.
|
||||
static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.relay_node = relay;
|
||||
p.hop_start = 4;
|
||||
p.hop_limit = 4 - hopsAway; // getHopsAway() == hop_start - hop_limit
|
||||
return p;
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
myNodeInfo.my_node_num = kLocalNode;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->clearTestNodes();
|
||||
shim->resetRouteHealthForTest();
|
||||
}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 1 — resolveLastByte (M1)
|
||||
// ===========================================================================
|
||||
|
||||
void test_resolve_none_when_empty(void)
|
||||
{
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, r.status);
|
||||
}
|
||||
|
||||
void test_resolve_zero_byte_is_none(void)
|
||||
{
|
||||
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel — never resolves.
|
||||
mockNodeDB->addNode(0x22222200, 0, true, 60); // last byte maps to 0xFF, not 0
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, true).status);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, false).status);
|
||||
}
|
||||
|
||||
void test_resolve_unique_neighbor(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct, fresh, last byte 0xAB
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_collision_is_ambiguous(void)
|
||||
{
|
||||
// Birthday collision: two fresh direct neighbors share last byte 0xAB.
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60);
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0, r.num); // never silently picks one
|
||||
}
|
||||
|
||||
void test_resolve_strict_excludes_stale(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // fresh
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100); // stale
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_strict_excludes_far(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor
|
||||
mockNodeDB->addNode(0x000006AB, 2, true, 60); // 2 hops away -> not a direct neighbor
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_lenient_includes_favorite_router(void)
|
||||
{
|
||||
// Unknown hop distance, but a favorite ROUTER: lenient gate accepts, strict gate does not.
|
||||
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xAB, false).status);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_lenient_collision_favorite_plus_neighbor(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor, same byte
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xAB, false).status);
|
||||
}
|
||||
|
||||
void test_resolve_skips_self(void)
|
||||
{
|
||||
// A node equal to us (last byte 0x11) must never resolve to ourselves.
|
||||
mockNodeDB->addNode(kLocalNode, 0, true, 60);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x11, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_skips_ignored(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, /*ignored=*/true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_0x00_maps_to_0xFF_and_collides(void)
|
||||
{
|
||||
// getLastByteOfNodeNum() maps ...00 -> 0xFF, so a ...00 node and a ...FF node collide on 0xFF.
|
||||
TEST_ASSERT_EQUAL_HEX8(0xFF, mockNodeDB->getLastByteOfNodeNum(0x11111100));
|
||||
mockNodeDB->addNode(0x11111100, 0, true, 60); // last byte 0xFF (remapped)
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xFF, true).status);
|
||||
mockNodeDB->addNode(0x222222FF, 0, true, 60); // genuine ...FF
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xFF, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_unique_helper(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
NodeNum out = 0;
|
||||
TEST_ASSERT_TRUE(mockNodeDB->resolveUniqueLastByte(0xAB, true, &out));
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, out);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60); // create collision
|
||||
TEST_ASSERT_FALSE(mockNodeDB->resolveUniqueLastByte(0xAB, true));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 2 — getNextHop (M2 send-path gate + M3 decay)
|
||||
// ===========================================================================
|
||||
|
||||
static constexpr NodeNum DEST = 0x000000B0; // DM destination (last byte 0xB0, distinct from 0xAB)
|
||||
|
||||
void test_getnexthop_unique_returns_byte(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // unique fresh neighbor with byte 0xAB
|
||||
auto nh = shim->getNextHop(DEST, /*relay=*/0x11);
|
||||
TEST_ASSERT_TRUE(nh.has_value());
|
||||
TEST_ASSERT_EQUAL_HEX8(0xAB, nh.value());
|
||||
}
|
||||
|
||||
void test_getnexthop_ambiguous_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60); // collision -> ambiguous
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_vanished_neighbor_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
// The only 0xAB node is stale -> strict gate yields None -> flood.
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100);
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_split_horizon_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
// relay_node == stored next_hop -> don't send it back the way it came.
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, /*relay=*/0xAB).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_broadcast_is_nullopt(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(NODENUM_BROADCAST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_decays_stale_route(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // a valid unique neighbor exists...
|
||||
// ...but the health record is older than the TTL, so the route should decay to flooding.
|
||||
shim->noteRouteLearned(DEST, 0xAB, millis() - (TTL + 5000));
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
// Decay also clears the persisted next_hop and the RAM health record.
|
||||
TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockNodeDB->getMeshNode(DEST)->next_hop);
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 3 — route-health helpers (M3)
|
||||
// ===========================================================================
|
||||
|
||||
void test_health_fresh_not_stale(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_NOT_NULL(h);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 1000 + TTL - 1));
|
||||
}
|
||||
|
||||
void test_health_ttl_expiry(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_TRUE(shim->isRouteStale(*h, 1000 + TTL)); // boundary is inclusive (>=)
|
||||
}
|
||||
|
||||
void test_health_ttl_rollover_safe(void)
|
||||
{
|
||||
const uint32_t learnAt = 0xFFFFFFFFu - 1000; // learned just before the millis() rollover
|
||||
shim->noteRouteLearned(DEST, 0xAB, learnAt);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
// 1500 ms later (wrapped to now=500): unsigned subtraction yields ~1500 ms, not "stale".
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 500));
|
||||
}
|
||||
|
||||
void test_health_failure_threshold(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
for (uint8_t i = 1; i < THRESH; i++)
|
||||
shim->noteRouteFailure(DEST);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH-1 failures: ok
|
||||
shim->noteRouteFailure(DEST);
|
||||
TEST_ASSERT_TRUE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH failures: stale
|
||||
}
|
||||
|
||||
void test_health_success_resets_failures(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteSuccess(DEST, 2000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 2000));
|
||||
}
|
||||
|
||||
void test_health_relearn_same_hop_keeps_failures(void)
|
||||
{
|
||||
// Anti-flap: an asymmetric reverse path re-teaching the same dead hop must not reset failures.
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteLearned(DEST, 0xAB, 2000); // same hop
|
||||
TEST_ASSERT_EQUAL_UINT8(2, shim->findRouteHealth(DEST)->consecutiveFailures);
|
||||
}
|
||||
|
||||
void test_health_relearn_new_hop_resets_failures(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteLearned(DEST, 0xCD, 2000); // genuinely new hop -> clean slate
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
|
||||
TEST_ASSERT_EQUAL_HEX8(0xCD, h->lastNextHop);
|
||||
}
|
||||
|
||||
void test_health_failure_without_record_is_noop(void)
|
||||
{
|
||||
shim->noteRouteFailure(DEST); // no record yet
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
void test_health_clear(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(DEST));
|
||||
shim->clearRouteHealth(DEST);
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
void test_health_lru_eviction_bounds_table(void)
|
||||
{
|
||||
// Fill every slot with increasing learn times, then add one more: the oldest must be evicted.
|
||||
for (uint8_t i = 0; i < HEALTH_MAX; i++)
|
||||
shim->noteRouteLearned(0x1000 + i, 0xAB, 1000 + (uint32_t)i * 1000);
|
||||
NodeNum oldest = 0x1000;
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(oldest));
|
||||
shim->noteRouteLearned(0x2000, 0xAB, 1000 + (uint32_t)HEALTH_MAX * 1000); // overflow
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(oldest)); // evicted
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(0x2000)); // newest present
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 4 — shouldDecrementHopLimit favorite-router resolution (M2, site 4)
|
||||
// ===========================================================================
|
||||
|
||||
void test_hoplimit_preserve_unique_favorite_router(void)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(/*relay=*/0xAB, /*hopsAway=*/1);
|
||||
TEST_ASSERT_FALSE(shim->shouldDecrementHopLimit(&p)); // preserve
|
||||
}
|
||||
|
||||
void test_hoplimit_decrement_on_colliding_favorites(void)
|
||||
{
|
||||
// Headline regression: two favorite routers share the relay byte -> ambiguous -> decrement
|
||||
// (the old "first NodeDB match wins" scan would non-deterministically preserve).
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
mockNodeDB->addNode(0x000008AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
|
||||
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // decrement
|
||||
}
|
||||
|
||||
void test_hoplimit_decrement_when_resolved_not_favorite(void)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/false);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
|
||||
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // unique but not a favorite -> decrement
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
shim = new NextHopRouterTestShim();
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
printf("\n=== resolveLastByte (M1) ===\n");
|
||||
RUN_TEST(test_resolve_none_when_empty);
|
||||
RUN_TEST(test_resolve_zero_byte_is_none);
|
||||
RUN_TEST(test_resolve_unique_neighbor);
|
||||
RUN_TEST(test_resolve_collision_is_ambiguous);
|
||||
RUN_TEST(test_resolve_strict_excludes_stale);
|
||||
RUN_TEST(test_resolve_strict_excludes_far);
|
||||
RUN_TEST(test_resolve_lenient_includes_favorite_router);
|
||||
RUN_TEST(test_resolve_lenient_collision_favorite_plus_neighbor);
|
||||
RUN_TEST(test_resolve_skips_self);
|
||||
RUN_TEST(test_resolve_skips_ignored);
|
||||
RUN_TEST(test_resolve_0x00_maps_to_0xFF_and_collides);
|
||||
RUN_TEST(test_resolve_unique_helper);
|
||||
|
||||
printf("\n=== getNextHop (M2 + M3 decay) ===\n");
|
||||
RUN_TEST(test_getnexthop_unique_returns_byte);
|
||||
RUN_TEST(test_getnexthop_ambiguous_floods);
|
||||
RUN_TEST(test_getnexthop_vanished_neighbor_floods);
|
||||
RUN_TEST(test_getnexthop_split_horizon_floods);
|
||||
RUN_TEST(test_getnexthop_broadcast_is_nullopt);
|
||||
RUN_TEST(test_getnexthop_decays_stale_route);
|
||||
|
||||
printf("\n=== route-health helpers (M3) ===\n");
|
||||
RUN_TEST(test_health_fresh_not_stale);
|
||||
RUN_TEST(test_health_ttl_expiry);
|
||||
RUN_TEST(test_health_ttl_rollover_safe);
|
||||
RUN_TEST(test_health_failure_threshold);
|
||||
RUN_TEST(test_health_success_resets_failures);
|
||||
RUN_TEST(test_health_relearn_same_hop_keeps_failures);
|
||||
RUN_TEST(test_health_relearn_new_hop_resets_failures);
|
||||
RUN_TEST(test_health_failure_without_record_is_noop);
|
||||
RUN_TEST(test_health_clear);
|
||||
RUN_TEST(test_health_lru_eviction_bounds_table);
|
||||
|
||||
printf("\n=== shouldDecrementHopLimit (M2 site 4) ===\n");
|
||||
RUN_TEST(test_hoplimit_preserve_unique_favorite_router);
|
||||
RUN_TEST(test_hoplimit_decrement_on_colliding_favorites);
|
||||
RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
@@ -26,17 +26,22 @@ class NodeDBTestShim : public NodeDB
|
||||
void runDemote() { demoteOldestHotNodesToWarm(); }
|
||||
void runCleanup() { cleanupMeshDB(); }
|
||||
|
||||
// Read back the role + protected category the warm tier cached for a node.
|
||||
bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); }
|
||||
|
||||
void clearHot()
|
||||
{
|
||||
meshNodes->clear();
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey)
|
||||
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey,
|
||||
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT)
|
||||
{
|
||||
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
||||
n.num = num;
|
||||
n.last_heard = lastHeard;
|
||||
n.role = role;
|
||||
if (favorite)
|
||||
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||
if (ignored)
|
||||
@@ -99,6 +104,34 @@ static void test_migration_demotesOldestKeepsKeepersAndSelf(void)
|
||||
TEST_ASSERT_TRUE(warmHasKey(2000 + 3)); // ...but its key kept in the warm tier
|
||||
}
|
||||
|
||||
// Eviction carries the device role + protected category into the warm tier. A TRACKER is
|
||||
// hop-protected but NOT eviction-protected, so it gets demoted with its key; the warm
|
||||
// record must report role=TRACKER / category=Role. A plain CLIENT carries role=CLIENT/None.
|
||||
static void test_migration_carriesRoleAndProtectedIntoWarm(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
||||
for (int i = 1; i <= extra; i++) {
|
||||
const auto role = (i == 3) ? meshtastic_Config_DeviceConfig_Role_TRACKER : meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true,
|
||||
/*withKey=*/true, role);
|
||||
}
|
||||
|
||||
db->runDemote();
|
||||
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
// TRACKER (i=3): demoted out of hot, key kept, role + protected carried into warm.
|
||||
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3));
|
||||
TEST_ASSERT_TRUE(warmHasKey(2000 + 3));
|
||||
TEST_ASSERT_TRUE(db->warmMeta(2000 + 3, role, prot));
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
||||
// CLIENT (i=4): also demoted, carries role=CLIENT / category=None.
|
||||
TEST_ASSERT_TRUE(db->warmMeta(2000 + 4, role, prot));
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_CLIENT, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
// Favourite handling: a favourite is never the eviction victim, even when it is
|
||||
// the oldest node in a full hot store.
|
||||
static void test_eviction_preservesFavorite(void)
|
||||
@@ -172,6 +205,7 @@ NDB_TEST_ENTRY void setup()
|
||||
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf);
|
||||
RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm);
|
||||
RUN_TEST(test_eviction_preservesFavorite);
|
||||
RUN_TEST(test_ignored_survivesEvictionAndCleanup);
|
||||
RUN_TEST(test_protectedCap_refusesBeyondLimit);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "MeshTypes.h" // Include BEFORE TestUtil.h — provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h)
|
||||
#include "TestUtil.h"
|
||||
#include <cstdlib>
|
||||
#include <unity.h>
|
||||
@@ -10,6 +11,7 @@
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
|
||||
#include "airtime.h"
|
||||
#include "mesh/CryptoEngine.h"
|
||||
#include "mesh/MeshService.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
@@ -28,6 +30,25 @@ constexpr NodeNum kLocalNode = 0x11111111;
|
||||
constexpr NodeNum kRemoteNode = 0x22222222;
|
||||
constexpr NodeNum kTargetNode = 0x33333333;
|
||||
|
||||
// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks
|
||||
// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global
|
||||
// airTime reporting 100% channel utilization for the enclosing scope.
|
||||
class ScopedBusyAirTime
|
||||
{
|
||||
public:
|
||||
ScopedBusyAirTime() : previous(airTime)
|
||||
{
|
||||
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++)
|
||||
busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period
|
||||
airTime = &busy;
|
||||
}
|
||||
~ScopedBusyAirTime() { airTime = previous; }
|
||||
|
||||
private:
|
||||
AirTime busy;
|
||||
AirTime *previous;
|
||||
};
|
||||
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
@@ -54,6 +75,32 @@ class MockNodeDB : public NodeDB
|
||||
cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||
}
|
||||
|
||||
// Role the TMM should see for the cached node (sender-role-aware throttles).
|
||||
void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; }
|
||||
|
||||
// Seed a node into the hot-store buffer at index 1 (index 0 is reserved for
|
||||
// "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of
|
||||
// MAX_NUM_NODES slots with `numMeshNodes` as the logical count — we grow the
|
||||
// buffer if needed and bump the count, never clear()/push_back() (which would
|
||||
// shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill).
|
||||
void setHotNode(NodeNum n, uint8_t nextHop)
|
||||
{
|
||||
if (meshNodes->size() < 2)
|
||||
meshNodes->resize(2);
|
||||
(*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero;
|
||||
(*meshNodes)[1].num = n;
|
||||
(*meshNodes)[1].next_hop = nextHop;
|
||||
numMeshNodes = 2;
|
||||
}
|
||||
|
||||
// Evict everything but "self" — simulates the hot DB rolling over. Logical
|
||||
// count only; the buffer is left intact so the invariant holds.
|
||||
void rollHotStore()
|
||||
{
|
||||
numMeshNodes = 1;
|
||||
clearCachedNode();
|
||||
}
|
||||
|
||||
private:
|
||||
bool hasCachedNode = false;
|
||||
NodeNum cachedNodeNum = 0;
|
||||
@@ -121,6 +168,9 @@ static void resetTrafficConfig()
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
|
||||
channelFile = meshtastic_ChannelFile_init_zero;
|
||||
owner.is_licensed = false;
|
||||
|
||||
myNodeInfo.my_node_num = kLocalNode;
|
||||
|
||||
router = nullptr;
|
||||
@@ -175,6 +225,42 @@ static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32
|
||||
return packet;
|
||||
}
|
||||
|
||||
static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits)
|
||||
{
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST);
|
||||
meshtastic_Position pos = meshtastic_Position_init_zero;
|
||||
pos.has_latitude_i = true;
|
||||
pos.has_longitude_i = true;
|
||||
pos.latitude_i = lat;
|
||||
pos.longitude_i = lon;
|
||||
pos.precision_bits = precisionBits;
|
||||
|
||||
packet.decoded.payload.size =
|
||||
pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos);
|
||||
return packet;
|
||||
}
|
||||
|
||||
static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out)
|
||||
{
|
||||
out = meshtastic_Position_init_zero;
|
||||
return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out);
|
||||
}
|
||||
|
||||
// Primary channel with a well-known single-byte PSK and the (empty -> preset)
|
||||
// default name, so Channels::isWellKnownChannel(0) is true.
|
||||
static void installWellKnownPrimaryChannel()
|
||||
{
|
||||
channelFile = meshtastic_ChannelFile_init_zero;
|
||||
channelFile.channels_count = 1;
|
||||
channelFile.channels[0].index = 0;
|
||||
channelFile.channels[0].has_settings = true;
|
||||
channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY;
|
||||
channelFile.channels[0].settings.psk.size = 1;
|
||||
channelFile.channels[0].settings.psk.bytes[0] = 1;
|
||||
config.lora.use_preset = true;
|
||||
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
||||
}
|
||||
|
||||
static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName)
|
||||
{
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST);
|
||||
@@ -290,7 +376,7 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
|
||||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||||
moduleConfig.traffic_management.rate_limit_max_packets = 3;
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
|
||||
|
||||
ProcessMessage r1 = module.handleReceived(packet);
|
||||
ProcessMessage r2 = module.handleReceived(packet);
|
||||
@@ -305,31 +391,6 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void)
|
||||
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
|
||||
TEST_ASSERT_TRUE(module.ignoreRequestFlag());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify routing/admin traffic is exempt from rate limiting.
|
||||
* Important because throttling control traffic can destabilize the mesh.
|
||||
*/
|
||||
static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void)
|
||||
{
|
||||
moduleConfig.traffic_management.rate_limit_enabled = true;
|
||||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||||
moduleConfig.traffic_management.rate_limit_max_packets = 1;
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode);
|
||||
meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ProcessMessage rr = module.handleReceived(routingPacket);
|
||||
ProcessMessage ar = module.handleReceived(adminPacket);
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(rr));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(ar));
|
||||
}
|
||||
|
||||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||||
TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify packets sourced from this node bypass dedup and rate limiting.
|
||||
* Important so local transmissions are not accidentally self-throttled.
|
||||
@@ -650,12 +711,15 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Verify relayed telemetry broadcasts are hop-exhausted when enabled.
|
||||
* Verify relayed telemetry broadcasts are hop-exhausted when enabled AND the
|
||||
* channel is congested (telemetry exhaustion is gated on channel utilization,
|
||||
* unlike position exhaustion).
|
||||
* Important to prevent further mesh propagation while still allowing one relay step.
|
||||
*/
|
||||
static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void)
|
||||
{
|
||||
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
|
||||
ScopedBusyAirTime busyChannel;
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
|
||||
packet.hop_start = 5;
|
||||
@@ -677,6 +741,7 @@ static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void)
|
||||
static void test_tm_alterReceived_skipsLocalAndUnicast(void)
|
||||
{
|
||||
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
|
||||
ScopedBusyAirTime busyChannel; // congestion satisfied, so only the skip conditions are under test
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode);
|
||||
@@ -794,8 +859,11 @@ static void test_tm_positionDedup_precision32_allowsDistinctPositions(void)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify invalid precision=0 is treated as full precision.
|
||||
* Important so invalid config does not collapse all positions into one fingerprint.
|
||||
* Verify precision=0 falls back to the default precision (same contract as
|
||||
* >32: getConfiguredOrDefault + sanitizePositionPrecision treat 0 as unset).
|
||||
* Important so invalid config does not collapse all positions into one
|
||||
* fingerprint — positions in different default-precision grid cells must
|
||||
* still be distinct.
|
||||
*/
|
||||
static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void)
|
||||
{
|
||||
@@ -805,7 +873,7 @@ static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void)
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678);
|
||||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677);
|
||||
meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678);
|
||||
|
||||
ProcessMessage r1 = module.handleReceived(first);
|
||||
ProcessMessage r2 = module.handleReceived(second);
|
||||
@@ -857,11 +925,11 @@ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero
|
||||
moduleConfig.traffic_management.rate_limit_max_packets = 10;
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
|
||||
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
|
||||
meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
|
||||
meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000);
|
||||
|
||||
ProcessMessage seeded = module.handleReceived(text);
|
||||
ProcessMessage seeded = module.handleReceived(telemetry);
|
||||
ProcessMessage r1 = module.handleReceived(first);
|
||||
ProcessMessage r2 = module.handleReceived(duplicate);
|
||||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||||
@@ -882,7 +950,7 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void)
|
||||
moduleConfig.traffic_management.rate_limit_window_secs = 1;
|
||||
moduleConfig.traffic_management.rate_limit_max_packets = 1;
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode);
|
||||
|
||||
ProcessMessage r1 = module.handleReceived(packet);
|
||||
ProcessMessage r2 = module.handleReceived(packet);
|
||||
@@ -895,30 +963,6 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void)
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(r3));
|
||||
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify rate-limit thresholds above 255 effectively clamp to 255.
|
||||
* Important because counters are uint8_t and must not overflow behavior.
|
||||
*/
|
||||
static void test_tm_rateLimit_thresholdAbove255_clamps(void)
|
||||
{
|
||||
moduleConfig.traffic_management.rate_limit_enabled = true;
|
||||
moduleConfig.traffic_management.rate_limit_window_secs = 60;
|
||||
moduleConfig.traffic_management.rate_limit_max_packets = 300;
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode);
|
||||
|
||||
for (int i = 0; i < 255; i++) {
|
||||
ProcessMessage result = module.handleReceived(packet);
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(result));
|
||||
}
|
||||
ProcessMessage dropped = module.handleReceived(packet);
|
||||
meshtastic_TrafficManagementStats stats = module.getStats();
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(dropped));
|
||||
TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify unknown-packet tracking resets after its active window expires.
|
||||
* Important so old unknown traffic does not trigger delayed drops.
|
||||
@@ -966,12 +1010,14 @@ static void test_tm_unknownPackets_thresholdAbove255_clamps(void)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify relayed position broadcasts can also be hop-exhausted.
|
||||
* Verify relayed position broadcasts can also be hop-exhausted — under the
|
||||
* same pressure gate as telemetry (here: channel congestion).
|
||||
* Important because telemetry and position use separate exhaust flags.
|
||||
*/
|
||||
static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
|
||||
{
|
||||
moduleConfig.traffic_management.exhaust_hop_position = true;
|
||||
ScopedBusyAirTime busyChannel;
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST);
|
||||
packet.hop_start = 5;
|
||||
@@ -985,7 +1031,6 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
|
||||
TEST_ASSERT_TRUE(module.shouldExhaustHops(packet));
|
||||
TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify hop exhaustion ignores undecoded/encrypted packets.
|
||||
* Important so we never mutate packets that were not decoded by this module.
|
||||
@@ -993,6 +1038,7 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void)
|
||||
static void test_tm_alterReceived_skipsUndecodedPackets(void)
|
||||
{
|
||||
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
|
||||
ScopedBusyAirTime busyChannel; // congestion satisfied, so only the undecoded skip is under test
|
||||
TrafficManagementModuleTestShim module;
|
||||
meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST);
|
||||
packet.hop_start = 5;
|
||||
@@ -1014,6 +1060,7 @@ static void test_tm_alterReceived_skipsUndecodedPackets(void)
|
||||
static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void)
|
||||
{
|
||||
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
|
||||
ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
|
||||
@@ -1039,6 +1086,7 @@ static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void)
|
||||
static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void)
|
||||
{
|
||||
moduleConfig.traffic_management.exhaust_hop_telemetry = true;
|
||||
ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST);
|
||||
@@ -1083,6 +1131,93 @@ static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void)
|
||||
TEST_ASSERT_EQUAL_INT32(60 * 1000, interval);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next-hop overflow cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Round-trip set/get of a confirmed next hop, plus the input guards.
|
||||
*/
|
||||
static void test_tm_nextHop_setAndGetRoundTrip(void)
|
||||
{
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
// Unknown node yields no hint.
|
||||
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode));
|
||||
|
||||
// Store a confirmed hop and read it back.
|
||||
module.setNextHop(kTargetNode, 0x42);
|
||||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||||
|
||||
// Zero dest and zero byte are rejected (no spurious entry created).
|
||||
module.setNextHop(0, 0x42);
|
||||
module.setNextHop(kRemoteNode, 0);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kRemoteNode));
|
||||
|
||||
// Last-write-wins on re-confirmation.
|
||||
module.setNextHop(kTargetNode, 0x99);
|
||||
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline scenario: a node carrying a next hop in the hot NodeInfoLite DB
|
||||
* is warm-loaded into the TMM cache, then the hot DB is "rolled" (the node ages
|
||||
* out entirely). The hint must still be served — now exclusively from TMM.
|
||||
*/
|
||||
static void test_tm_nextHop_servedAfterNodeDbRoll(void)
|
||||
{
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
// Seed the hot NodeInfoLite DB with a node that has a confirmed next hop.
|
||||
mockNodeDB->setHotNode(kTargetNode, 0x42);
|
||||
|
||||
// Warm-start the overflow cache from the hot DB.
|
||||
module.preloadNextHopsFromNodeDB();
|
||||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||||
|
||||
// Roll the main NodeInfoLite DB: the node is evicted from the hot store.
|
||||
mockNodeDB->rollHotStore();
|
||||
TEST_ASSERT_NULL(nodeDB->getMeshNode(kTargetNode)); // gone from the hot store
|
||||
|
||||
// Hit is still served — proving it now comes from the TMM overflow cache.
|
||||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload must not clobber a freshly-learned (confirmed) hop with a possibly
|
||||
* stale persisted one from NodeInfoLite.
|
||||
*/
|
||||
static void test_tm_nextHop_preloadDoesNotClobberLearned(void)
|
||||
{
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
// A fresher confirmed hop is already cached.
|
||||
module.setNextHop(kTargetNode, 0x99);
|
||||
|
||||
// The hot DB carries an older next hop for the same node.
|
||||
mockNodeDB->setHotNode(kTargetNode, 0x42);
|
||||
|
||||
module.preloadNextHopsFromNodeDB();
|
||||
|
||||
// The freshly-learned hop survives.
|
||||
TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* A pure routing hint (no dedup/rate/unknown state) must survive the maintenance
|
||||
* sweep — next_hop != 0 keeps the slot alive even though it has no TTL.
|
||||
*/
|
||||
static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void)
|
||||
{
|
||||
TrafficManagementModuleTestShim module;
|
||||
|
||||
module.setNextHop(kTargetNode, 0x42);
|
||||
|
||||
// The sweep frees slots whose sub-stores are all empty; next_hop must veto that.
|
||||
module.runOnce();
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void setUp(void)
|
||||
@@ -1106,7 +1241,6 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow);
|
||||
RUN_TEST(test_tm_positionDedup_allowsMovedPosition);
|
||||
RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold);
|
||||
RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts);
|
||||
RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters);
|
||||
RUN_TEST(test_tm_localDestination_bypassesTransitFilters);
|
||||
RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops);
|
||||
@@ -1130,7 +1264,6 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset);
|
||||
RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero);
|
||||
RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires);
|
||||
RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps);
|
||||
RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires);
|
||||
RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps);
|
||||
RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast);
|
||||
@@ -1139,6 +1272,10 @@ TM_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped);
|
||||
RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval);
|
||||
RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval);
|
||||
RUN_TEST(test_tm_nextHop_setAndGetRoundTrip);
|
||||
RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll);
|
||||
RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned);
|
||||
RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "mesh/WarmNodeStore.h"
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -76,12 +78,15 @@ void test_ws_take_removesEntry()
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 3);
|
||||
ws.absorb(0x400, 1234, key);
|
||||
ws.absorb(0x400, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(ws.take(0x400, e));
|
||||
TEST_ASSERT_EQUAL(0x400, e.num);
|
||||
TEST_ASSERT_EQUAL(1234, e.last_heard);
|
||||
// last_heard is quantised to the metadata quantum; role/protected ride the low bits.
|
||||
TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, e.public_key, 32);
|
||||
TEST_ASSERT_FALSE(ws.contains(0x400));
|
||||
TEST_ASSERT_FALSE(ws.take(0x400, e));
|
||||
@@ -111,13 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst()
|
||||
// Fill with keyed entries except two keyless ones in the middle
|
||||
for (size_t i = 0; i < ws.capacity(); i++) {
|
||||
const bool keyless = (i == 5 || i == 10);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, keyless ? (i == 10 ? 50 : 60) : 10, keyless ? NULL : key));
|
||||
// Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives
|
||||
// quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key));
|
||||
}
|
||||
// Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50),
|
||||
// even though every keyed entry is older (ts=10)
|
||||
uint8_t k2[32];
|
||||
makeKey(k2, 0x43);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70, k2));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2));
|
||||
TEST_ASSERT_FALSE(ws.contains(0x1000 + 10));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x1000 + 5));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x8888));
|
||||
@@ -139,6 +146,31 @@ void test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless()
|
||||
TEST_ASSERT_EQUAL(ws.capacity(), ws.count());
|
||||
}
|
||||
|
||||
void test_ws_meta_roundTrip()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 0x77);
|
||||
// Keyed TRACKER(5)/Role-protected and keyless SENSOR(6)/unprotected.
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x700, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x701, 5678, NULL, 6 /* SENSOR */, (uint8_t)WarmProtected::None));
|
||||
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x700, role, prot));
|
||||
TEST_ASSERT_EQUAL(5, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x701, role, prot));
|
||||
TEST_ASSERT_EQUAL(6, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
// Absent node yields false and leaves outputs untouched-by-contract (just check return).
|
||||
TEST_ASSERT_FALSE(ws.lookupMeta(0x999, role, prot));
|
||||
// Default args still compile (role/protected = 0 = CLIENT/None).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x702, 9999, NULL));
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x702, role, prot));
|
||||
TEST_ASSERT_EQUAL(0, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
void test_ws_remove_and_clear()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
@@ -175,6 +207,56 @@ void test_ws_persistence_roundTrip()
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
// Migration: a v1 (WRM1) warm.dat must keep identity + key but discard last_heard
|
||||
// (so its low bits aren't misread as role/protected). File backend only.
|
||||
void test_ws_v1_migration_discardsLastHeard()
|
||||
{
|
||||
WarmNodeStore a;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 0x66);
|
||||
a.absorb(0x900, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
|
||||
if (!a.saveIfDirty()) {
|
||||
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the whole v2 file, flip the 4-byte header magic to v1 ("WRM1"), write it back.
|
||||
// (CRC covers only the entry bytes, so patching the header magic keeps it valid.)
|
||||
std::vector<uint8_t> buf;
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ);
|
||||
if (!f) {
|
||||
TEST_IGNORE_MESSAGE("warm.dat not readable in this environment");
|
||||
return;
|
||||
}
|
||||
buf.resize(f.size());
|
||||
f.read(buf.data(), buf.size());
|
||||
f.close();
|
||||
}
|
||||
TEST_ASSERT_TRUE(buf.size() >= 4);
|
||||
const uint32_t v1magic = 0x314D5257u; // "WRM1"
|
||||
memcpy(buf.data(), &v1magic, sizeof(v1magic));
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE);
|
||||
TEST_ASSERT_TRUE((bool)f);
|
||||
f.write(buf.data(), buf.size());
|
||||
f.close();
|
||||
}
|
||||
|
||||
WarmNodeStore b;
|
||||
b.load();
|
||||
TEST_ASSERT_TRUE(b.contains(0x900)); // identity survived migration
|
||||
TEST_ASSERT_TRUE(b.copyKey(0x900, got)); // public key survived
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
TEST_ASSERT_TRUE(b.lookupMeta(0x900, role, prot));
|
||||
TEST_ASSERT_EQUAL(0, role); // last_heard discarded → role/protected reset
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
|
||||
b.clear();
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
WS_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -187,8 +269,10 @@ WS_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_ws_keylessCandidate_neverEvictsKeyedEntries);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless);
|
||||
RUN_TEST(test_ws_meta_roundTrip);
|
||||
RUN_TEST(test_ws_remove_and_clear);
|
||||
RUN_TEST(test_ws_persistence_roundTrip);
|
||||
RUN_TEST(test_ws_v1_migration_discardsLastHeard);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user