From 3d5f9f6eb1a1c9fd7fbc15f9e2ce019683cfe78a Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:35:03 -0700 Subject: [PATCH 1/8] docs: specify direct NodeInfo discovery --- ...deinfo-direct-neighbor-discovery-design.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md diff --git a/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md b/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md new file mode 100644 index 00000000000..4082d06beca --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md @@ -0,0 +1,41 @@ +# NodeInfo direct-neighbor discovery + +## Problem + +A reset or radio-generation change sends a broadcast NodeInfo with +`want_response`. In a large mesh every receiver can answer, and normal reply +routing gives a direct receiver a two-hop reply. That creates an avoidable +fan-out burst while the existing 12-hour suppression window can also leave a +fresh node with no immediate peer identity. + +## Design + +Keep the discovery announcement as a normal broadcast so the new node's +identity remains visible throughout the mesh. Restrict the response: + +- Only a broadcast `NODEINFO_APP` request received at exactly zero hops may + produce a NodeInfo response. +- That response is unicast to the requester with `hop_limit = 0`; it cannot be + relayed. +- Relayed broadcasts and packets whose hop distance cannot be established do + not reply and suppress the automatic no-response NAK. +- Unicast NodeInfo exchanges, phone/API-triggered requests, and traffic + management direct-response behavior remain unchanged. + +The hop predicate uses `getHopsAway()`, which treats a decoded packet with +`hop_start == hop_limit` as a direct radio reception and rejects legacy or +malformed packets when the distance is unknown. + +## Implementation + +Add NodeInfo-specific helpers for qualifying a broadcast discovery request and +for choosing its reply hop limit. The generic `MeshModule` response path calls +the module hook after `setReplyTo()`, so only NodeInfo can replace the normal +return-path calculation with zero hops. + +## Verification + +Add native tests for direct, relayed, and unknown-hop broadcast NodeInfo +requests, plus a unicast control. Verify that only the direct broadcast reply +has a zero hop limit. Run formatting and the targeted native test suite; run +the full native wrapper when its host dependencies permit it. From ebf6a067704bf186c69bdcdd5d0060cb368b1fd9 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:37:14 -0700 Subject: [PATCH 2/8] docs: plan direct NodeInfo discovery --- ...7-21-nodeinfo-direct-neighbor-discovery.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md diff --git a/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md b/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md new file mode 100644 index 00000000000..a379e096835 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md @@ -0,0 +1,59 @@ +# NodeInfo Direct-Neighbor Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Limit reset-discovery NodeInfo replies to direct radio neighbors and keep each reply at zero hops. + +**Architecture:** Keep generic reply construction in `MeshModule`. Add a protected virtual response-hop hook whose default delegates to `RoutingModule`; override it in `NodeInfoModule` only for direct broadcast NodeInfo discovery. Its allocator rejects relayed and unknown-distance broadcast requests while retaining unicast behavior. + +**Tech Stack:** C++17, Unity native tests, PlatformIO, trunk. + +## Global Constraints + +- Do not change unicast NodeInfo response behavior. +- A broadcast request qualifies only when `getHopsAway(request) == 0`. +- Qualifying reply packets must have `hop_limit = 0`. +- Suppressed broadcasts must not result in a routing NO_RESPONSE NAK. +- Do not edit generated protobuf sources. + +--- + +### Task 1: Add a response-hop hook + +**Files:** +- Modify: `src/mesh/MeshModule.h:226-239` +- Modify: `src/mesh/MeshModule.cpp:231-240` +- Test: `test/test_mesh_module/test_main.cpp` + +**Interfaces:** Produces `virtual uint8_t MeshModule::getResponseHopLimit(const meshtastic_MeshPacket &req)`, which returns `routingModule->getHopLimitForResponse(req)` by default. + +- [ ] **Step 1: Write the failing test** — Add a synthetic reply module overriding `getResponseHopLimit()` to return `0`; dispatch a request and assert its captured reply has `hop_limit == 0`. +- [ ] **Step 2: Run the failing test** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because the hook does not exist. +- [ ] **Step 3: Write minimal implementation** — Declare the protected virtual hook in `MeshModule`; implement it using `routingModule->getHopLimitForResponse(req)`; after `setReplyTo(r, req)` in `sendResponse`, assign `r->hop_limit = getResponseHopLimit(req)`. +- [ ] **Step 4: Run the focused test** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. +- [ ] **Step 5: Commit** — `git add src/mesh/MeshModule.h src/mesh/MeshModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "refactor: allow modules to constrain response hop limits"`. + +### Task 2: Restrict NodeInfo broadcast discovery + +**Files:** +- Modify: `src/modules/NodeInfoModule.h:42-56` +- Modify: `src/modules/NodeInfoModule.cpp:138-192` +- Test: `test/test_mesh_module/test_main.cpp` + +**Interfaces:** Consumes Task 1's hook and `getHopsAway()` from `src/mesh/NodeDB.h`. Produces NodeInfo replies only for direct broadcast discovery with zero hops. + +- [ ] **Step 1: Write failing tests** — Build direct (`hop_start == hop_limit`), relayed (`hop_limit < hop_start`), unknown-hop (zero hop start and no decoded bitfield), and unicast NodeInfo requests. Assert only direct broadcast qualifies and its reply hop limit is zero; unicast retains the routing default. +- [ ] **Step 2: Run the failing test** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because NodeInfo lacks the policy hook. +- [ ] **Step 3: Write minimal implementation** — In `NodeInfoModule::allocReply`, detect an external broadcast request and return `NULL` with `ignoreRequest = true` unless `getHopsAway(*currentRequest) == 0`. Override the hook to return `0` only for a broadcast request at zero hops and otherwise call `MeshModule::getResponseHopLimit(req)`. +- [ ] **Step 4: Run focused tests** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS for direct, relayed, unknown-hop, and unicast controls. +- [ ] **Step 5: Commit** — `git add src/modules/NodeInfoModule.h src/modules/NodeInfoModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "fix: limit NodeInfo discovery to direct neighbors"`. + +### Task 3: Format and validate + +**Files:** +- Modify: only formatting reported by `trunk fmt`. + +- [ ] **Step 1: Format** — Run `trunk fmt`; expect exit 0. +- [ ] **Step 2: Run focused native tests** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. +- [ ] **Step 3: Run repository wrapper** — Run `./bin/run-tests.sh`; expect GREEN, or record the exact host blocker without calling it a pass. +- [ ] **Step 4: Inspect and commit** — Run `git diff --check origin/develop...HEAD`, `git status --short`, and `git log --format='%h %an <%ae> %s' origin/develop..HEAD`; then commit validation-only formatting changes if present. From 00fd94e9e8eac0b4c74db441357c7aab27a075cb Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:38:20 -0700 Subject: [PATCH 3/8] refactor: allow modules to constrain response hop limits --- src/mesh/MeshModule.cpp | 6 ++++++ src/mesh/MeshModule.h | 3 +++ test/test_mesh_module/test_main.cpp | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index 5dc7fba4a37..4b75917d8df 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -224,6 +224,11 @@ meshtastic_MeshPacket *MeshModule::allocReply() return r; } +uint8_t MeshModule::getResponseHopLimit(const meshtastic_MeshPacket &req) +{ + return routingModule->getHopLimitForResponse(req); +} + /** Messages can be received that have the want_response bit set. If set, this callback will be invoked * so that subclasses can (optionally) send a response back to the original sender. Implementing this method * is optional @@ -233,6 +238,7 @@ void MeshModule::sendResponse(const meshtastic_MeshPacket &req) auto r = allocReply(); if (r) { setReplyTo(r, req); + r->hop_limit = getResponseHopLimit(req); currentReply = r; } else { // Ignore - this is now expected behavior for routing module (because it ignores some replies) diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 3dc6414a515..8806ee0175d 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -231,6 +231,9 @@ class MeshModule friend class ReliableRouter; + protected: + virtual uint8_t getResponseHopLimit(const meshtastic_MeshPacket &req); + /** Messages can be received that have the want_response bit set. If set, this callback will be invoked * so that subclasses can (optionally) send a response back to the original sender. This method calls allocReply() * to generate the reply message, and if !NULL that message will be delivered to whoever sent req diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index 6ba038af37a..da698ad393b 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -126,6 +126,22 @@ class SyntheticReplyModule : public MeshModule bool acceptsEveryPort; }; +class ZeroHopReplyModule : public SyntheticReplyModule +{ + public: + ZeroHopReplyModule() + : SyntheticReplyModule("zero-hop-reply", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP) + { + } + + protected: + uint8_t getResponseHopLimit(const meshtastic_MeshPacket &req) override + { + (void)req; + return 0; + } +}; + class ObservingIgnoreModule : public MeshModule { public: @@ -418,6 +434,16 @@ static void test_dispatch_crossPortReplyUsesRequestOwner() TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); } +static void test_dispatch_moduleCanConstrainReplyHopLimit() +{ + registerDispatchModule(new ZeroHopReplyModule()); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT8(0, mockRouter->sentPackets[0].hop_limit); +} + static void test_dispatch_foreignPortObserverCanSuppressNak() { auto *observer = registerDispatchModule(new ObservingIgnoreModule()); @@ -491,6 +517,7 @@ void setup() RUN_TEST(test_dispatch_foreignPortOffenderCannotShadowOwner); RUN_TEST(test_dispatch_ownerPortStillReplies); RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner); + RUN_TEST(test_dispatch_moduleCanConstrainReplyHopLimit); RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak); RUN_TEST(test_dispatch_noResponderSendsNak); RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); From 0180ec5aada0eea42c18c8a113177a30071c4a45 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:44:18 -0700 Subject: [PATCH 4/8] fix: limit NodeInfo discovery to direct neighbors --- ...7-21-nodeinfo-direct-neighbor-discovery.md | 34 +++++++------ ...deinfo-direct-neighbor-discovery-design.md | 8 +-- src/mesh/MeshModule.h | 1 + src/modules/NodeInfoModule.cpp | 26 +++++++++- src/modules/NodeInfoModule.h | 4 ++ test/test_mesh_module/test_main.cpp | 50 +++++++++++++++++++ 6 files changed, 104 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md b/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md index a379e096835..d380eb83817 100644 --- a/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md +++ b/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md @@ -4,13 +4,14 @@ **Goal:** Limit reset-discovery NodeInfo replies to direct radio neighbors and keep each reply at zero hops. -**Architecture:** Keep generic reply construction in `MeshModule`. Add a protected virtual response-hop hook whose default delegates to `RoutingModule`; override it in `NodeInfoModule` only for direct broadcast NodeInfo discovery. Its allocator rejects relayed and unknown-distance broadcast requests while retaining unicast behavior. +**Architecture:** `NodeInfoModule` emits every broadcast request for replies at hop limit zero. Keep generic reply construction in `MeshModule`; add a protected virtual response-hop hook whose default delegates to `RoutingModule`, then override it in `NodeInfoModule` only for direct broadcast NodeInfo discovery. Its allocator rejects relayed and unknown-distance broadcast requests while retaining unicast behavior. **Tech Stack:** C++17, Unity native tests, PlatformIO, trunk. ## Global Constraints - Do not change unicast NodeInfo response behavior. +- Broadcast NodeInfo requests that ask for replies must be emitted at hop limit zero. - A broadcast request qualifies only when `getHopsAway(request) == 0`. - Qualifying reply packets must have `hop_limit = 0`. - Suppressed broadcasts must not result in a routing NO_RESPONSE NAK. @@ -21,39 +22,42 @@ ### Task 1: Add a response-hop hook **Files:** + - Modify: `src/mesh/MeshModule.h:226-239` - Modify: `src/mesh/MeshModule.cpp:231-240` - Test: `test/test_mesh_module/test_main.cpp` **Interfaces:** Produces `virtual uint8_t MeshModule::getResponseHopLimit(const meshtastic_MeshPacket &req)`, which returns `routingModule->getHopLimitForResponse(req)` by default. -- [ ] **Step 1: Write the failing test** — Add a synthetic reply module overriding `getResponseHopLimit()` to return `0`; dispatch a request and assert its captured reply has `hop_limit == 0`. -- [ ] **Step 2: Run the failing test** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because the hook does not exist. -- [ ] **Step 3: Write minimal implementation** — Declare the protected virtual hook in `MeshModule`; implement it using `routingModule->getHopLimitForResponse(req)`; after `setReplyTo(r, req)` in `sendResponse`, assign `r->hop_limit = getResponseHopLimit(req)`. -- [ ] **Step 4: Run the focused test** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. -- [ ] **Step 5: Commit** — `git add src/mesh/MeshModule.h src/mesh/MeshModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "refactor: allow modules to constrain response hop limits"`. +- [ ] **Step 1: Write the failing test** - Add a synthetic reply module overriding `getResponseHopLimit()` to return `0`; dispatch a request and assert its captured reply has `hop_limit == 0`. +- [ ] **Step 2: Run the failing test** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because the hook does not exist. +- [ ] **Step 3: Write minimal implementation** - Declare the protected virtual hook in `MeshModule`; implement it using `routingModule->getHopLimitForResponse(req)`; after `setReplyTo(r, req)` in `sendResponse`, assign `r->hop_limit = getResponseHopLimit(req)`. +- [ ] **Step 4: Run the focused test** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. +- [ ] **Step 5: Commit** - `git add src/mesh/MeshModule.h src/mesh/MeshModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "refactor: allow modules to constrain response hop limits"`. ### Task 2: Restrict NodeInfo broadcast discovery **Files:** + - Modify: `src/modules/NodeInfoModule.h:42-56` - Modify: `src/modules/NodeInfoModule.cpp:138-192` - Test: `test/test_mesh_module/test_main.cpp` **Interfaces:** Consumes Task 1's hook and `getHopsAway()` from `src/mesh/NodeDB.h`. Produces NodeInfo replies only for direct broadcast discovery with zero hops. -- [ ] **Step 1: Write failing tests** — Build direct (`hop_start == hop_limit`), relayed (`hop_limit < hop_start`), unknown-hop (zero hop start and no decoded bitfield), and unicast NodeInfo requests. Assert only direct broadcast qualifies and its reply hop limit is zero; unicast retains the routing default. -- [ ] **Step 2: Run the failing test** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because NodeInfo lacks the policy hook. -- [ ] **Step 3: Write minimal implementation** — In `NodeInfoModule::allocReply`, detect an external broadcast request and return `NULL` with `ignoreRequest = true` unless `getHopsAway(*currentRequest) == 0`. Override the hook to return `0` only for a broadcast request at zero hops and otherwise call `MeshModule::getResponseHopLimit(req)`. -- [ ] **Step 4: Run focused tests** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS for direct, relayed, unknown-hop, and unicast controls. -- [ ] **Step 5: Commit** — `git add src/modules/NodeInfoModule.h src/modules/NodeInfoModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "fix: limit NodeInfo discovery to direct neighbors"`. +- [ ] **Step 1: Write failing tests** - Build direct (`hop_start == hop_limit`), relayed (`hop_limit < hop_start`), unknown-hop (zero hop start and no decoded bitfield), and unicast NodeInfo requests. Assert only direct broadcast qualifies and its reply hop limit is zero; assert `sendOurNodeInfo(NODENUM_BROADCAST, true)` sets `hop_limit` to zero; unicast retains the routing default. +- [ ] **Step 2: Run the failing test** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because NodeInfo lacks the policy hook. +- [ ] **Step 3: Write minimal implementation** - In `sendOurNodeInfo`, set `hop_limit = 0` for a broadcast request that asks for replies. In `NodeInfoModule::allocReply`, detect an external broadcast request and return `NULL` with `ignoreRequest = true` unless `getHopsAway(*currentRequest) == 0`. Override the hook to return `0` only for a broadcast request at zero hops and otherwise call `MeshModule::getResponseHopLimit(req)`. +- [ ] **Step 4: Run focused tests** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS for direct, relayed, unknown-hop, and unicast controls. +- [ ] **Step 5: Commit** - `git add src/modules/NodeInfoModule.h src/modules/NodeInfoModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "fix: limit NodeInfo discovery to direct neighbors"`. ### Task 3: Format and validate **Files:** + - Modify: only formatting reported by `trunk fmt`. -- [ ] **Step 1: Format** — Run `trunk fmt`; expect exit 0. -- [ ] **Step 2: Run focused native tests** — Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. -- [ ] **Step 3: Run repository wrapper** — Run `./bin/run-tests.sh`; expect GREEN, or record the exact host blocker without calling it a pass. -- [ ] **Step 4: Inspect and commit** — Run `git diff --check origin/develop...HEAD`, `git status --short`, and `git log --format='%h %an <%ae> %s' origin/develop..HEAD`; then commit validation-only formatting changes if present. +- [ ] **Step 1: Format** - Run `trunk fmt`; expect exit 0. +- [ ] **Step 2: Run focused native tests** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. +- [ ] **Step 3: Run repository wrapper** - Run `./bin/run-tests.sh`; expect GREEN, or record the exact host blocker without calling it a pass. +- [ ] **Step 4: Inspect and commit** - Run `git diff --check origin/develop...HEAD`, `git status --short`, and `git log --format='%h %an <%ae> %s' origin/develop..HEAD`; then commit validation-only formatting changes if present. diff --git a/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md b/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md index 4082d06beca..ebe7ad51fde 100644 --- a/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md +++ b/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md @@ -10,11 +10,13 @@ fresh node with no immediate peer identity. ## Design -Keep the discovery announcement as a normal broadcast so the new node's -identity remains visible throughout the mesh. Restrict the response: +Send the discovery announcement as a zero-hop broadcast so the new node learns +only its direct radio neighborhood. Restrict the response as a second line of +defense: +- Broadcast NodeInfo requests that ask for replies have `hop_limit = 0`. - Only a broadcast `NODEINFO_APP` request received at exactly zero hops may - produce a NodeInfo response. + produce a NodeInfo response, including requests sent by older firmware. - That response is unicast to the requester with `hop_limit = 0`; it cannot be relayed. - Relayed broadcasts and packets whose hop distance cannot be established do diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 8806ee0175d..856720dc37b 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -234,6 +234,7 @@ class MeshModule protected: virtual uint8_t getResponseHopLimit(const meshtastic_MeshPacket &req); + private: /** Messages can be received that have the want_response bit set. If set, this callback will be invoked * so that subclasses can (optionally) send a response back to the original sender. This method calls allocReply() * to generate the reply message, and if !NULL that message will be delivered to whoever sent req diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index f7bfcf2fe83..11117549645 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -113,6 +113,8 @@ void NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha wantReplies; p->decoded.want_response = requestWantResponse; + if (requestWantResponse && isBroadcast(dest)) + p->hop_limit = 0; if (_shorterTimeout) p->priority = meshtastic_MeshPacket_Priority_DEFAULT; else @@ -135,6 +137,21 @@ void NodeInfoModule::triggerImmediateNodeInfoCheck() setIntervalFromNow(0); } +bool NodeInfoModule::isDirectBroadcastDiscoveryRequest(const meshtastic_MeshPacket &request) +{ + return request.which_payload_variant == meshtastic_MeshPacket_decoded_tag && + request.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && request.decoded.want_response && + isBroadcast(request.to) && getHopsAway(request) == 0; +} + +uint8_t NodeInfoModule::getResponseHopLimit(const meshtastic_MeshPacket &req) +{ + if (isDirectBroadcastDiscoveryRequest(req)) + return 0; + + return MeshModule::getResponseHopLimit(req); +} + meshtastic_MeshPacket *NodeInfoModule::allocReply() { // Only apply suppression when actually replying to someone else's request, not for periodic broadcasts. @@ -143,6 +160,13 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply() currentRequest->decoded.portnum == meshtastic_PortNum_NODEINFO_APP && currentRequest->decoded.want_response && !isFromUs(currentRequest); + const bool isBroadcastRequest = isReplyingToExternalRequest && isBroadcast(currentRequest->to); + if (isBroadcastRequest && !isDirectBroadcastDiscoveryRequest(*currentRequest)) { + LOG_DEBUG("Skip NodeInfo response to non-direct broadcast discovery"); + ignoreRequest = true; + return NULL; + } + if (suppressReplyForCurrentRequest && isReplyingToExternalRequest) { LOG_DEBUG("Skip send NodeInfo since we heard the requester <12h ago"); ignoreRequest = true; @@ -234,4 +258,4 @@ int32_t NodeInfoModule::runOnce() sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); -} \ No newline at end of file +} diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 9b3b66caed4..f9fcc5cf6b8 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -31,6 +31,10 @@ class NodeInfoModule : public ProtobufModule, private concurren void triggerImmediateNodeInfoCheck(); protected: + static bool isDirectBroadcastDiscoveryRequest(const meshtastic_MeshPacket &request); + + virtual uint8_t getResponseHopLimit(const meshtastic_MeshPacket &req) override; + /** Called to handle a particular incoming message @return true if you've guaranteed you've handled this message and no other handlers should be considered for it diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index da698ad393b..ed8217d4380 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -10,6 +10,7 @@ #include "mesh/RadioInterface.h" #include "mesh/Router.h" #include "modules/NeighborInfoModule.h" +#include "modules/NodeInfoModule.h" #include "modules/RoutingModule.h" #include "support/MockMeshService.h" #include @@ -142,6 +143,13 @@ class ZeroHopReplyModule : public SyntheticReplyModule } }; +class NodeInfoPolicyShim : public NodeInfoModule +{ + public: + using NodeInfoModule::getResponseHopLimit; + using NodeInfoModule::isDirectBroadcastDiscoveryRequest; +}; + class ObservingIgnoreModule : public MeshModule { public: @@ -444,6 +452,45 @@ static void test_dispatch_moduleCanConstrainReplyHopLimit() TEST_ASSERT_EQUAL_UINT8(0, mockRouter->sentPackets[0].hop_limit); } +static void test_nodeInfo_directBroadcastDiscoveryUsesZeroHopReply() +{ + NodeInfoPolicyShim nodeInfo; + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_NODEINFO_APP); + request.to = NODENUM_BROADCAST; + request.hop_start = 3; + request.hop_limit = 3; + request.decoded.has_bitfield = true; + + TEST_ASSERT_TRUE(NodeInfoPolicyShim::isDirectBroadcastDiscoveryRequest(request)); + TEST_ASSERT_EQUAL_UINT8(0, nodeInfo.getResponseHopLimit(request)); +} + +static void test_nodeInfo_relayedAndUnknownBroadcastDiscoveryDoNotQualify() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_NODEINFO_APP); + request.to = NODENUM_BROADCAST; + request.hop_start = 3; + request.hop_limit = 2; + request.decoded.has_bitfield = true; + + TEST_ASSERT_FALSE(NodeInfoPolicyShim::isDirectBroadcastDiscoveryRequest(request)); + + request.hop_start = 0; + request.hop_limit = 0; + request.decoded.has_bitfield = false; + + TEST_ASSERT_FALSE(NodeInfoPolicyShim::isDirectBroadcastDiscoveryRequest(request)); +} + +static void test_nodeInfo_unicastRequestRetainsRoutingHopLimit() +{ + NodeInfoPolicyShim nodeInfo; + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_NODEINFO_APP); + + TEST_ASSERT_FALSE(NodeInfoPolicyShim::isDirectBroadcastDiscoveryRequest(request)); + TEST_ASSERT_EQUAL_UINT8(mockRoutingModule->getHopLimitForResponse(request), nodeInfo.getResponseHopLimit(request)); +} + static void test_dispatch_foreignPortObserverCanSuppressNak() { auto *observer = registerDispatchModule(new ObservingIgnoreModule()); @@ -518,6 +565,9 @@ void setup() RUN_TEST(test_dispatch_ownerPortStillReplies); RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner); RUN_TEST(test_dispatch_moduleCanConstrainReplyHopLimit); + RUN_TEST(test_nodeInfo_directBroadcastDiscoveryUsesZeroHopReply); + RUN_TEST(test_nodeInfo_relayedAndUnknownBroadcastDiscoveryDoNotQualify); + RUN_TEST(test_nodeInfo_unicastRequestRetainsRoutingHopLimit); RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak); RUN_TEST(test_dispatch_noResponderSendsNak); RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); From 9e54a9fbae6b5e81a1ccee177503cca65f8eeb10 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:46:52 -0700 Subject: [PATCH 5/8] chore: remove internal planning docs --- ...7-21-nodeinfo-direct-neighbor-discovery.md | 63 ------------------- ...deinfo-direct-neighbor-discovery-design.md | 43 ------------- 2 files changed, 106 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md delete mode 100644 docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md diff --git a/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md b/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md deleted file mode 100644 index d380eb83817..00000000000 --- a/docs/superpowers/plans/2026-07-21-nodeinfo-direct-neighbor-discovery.md +++ /dev/null @@ -1,63 +0,0 @@ -# NodeInfo Direct-Neighbor Discovery Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Limit reset-discovery NodeInfo replies to direct radio neighbors and keep each reply at zero hops. - -**Architecture:** `NodeInfoModule` emits every broadcast request for replies at hop limit zero. Keep generic reply construction in `MeshModule`; add a protected virtual response-hop hook whose default delegates to `RoutingModule`, then override it in `NodeInfoModule` only for direct broadcast NodeInfo discovery. Its allocator rejects relayed and unknown-distance broadcast requests while retaining unicast behavior. - -**Tech Stack:** C++17, Unity native tests, PlatformIO, trunk. - -## Global Constraints - -- Do not change unicast NodeInfo response behavior. -- Broadcast NodeInfo requests that ask for replies must be emitted at hop limit zero. -- A broadcast request qualifies only when `getHopsAway(request) == 0`. -- Qualifying reply packets must have `hop_limit = 0`. -- Suppressed broadcasts must not result in a routing NO_RESPONSE NAK. -- Do not edit generated protobuf sources. - ---- - -### Task 1: Add a response-hop hook - -**Files:** - -- Modify: `src/mesh/MeshModule.h:226-239` -- Modify: `src/mesh/MeshModule.cpp:231-240` -- Test: `test/test_mesh_module/test_main.cpp` - -**Interfaces:** Produces `virtual uint8_t MeshModule::getResponseHopLimit(const meshtastic_MeshPacket &req)`, which returns `routingModule->getHopLimitForResponse(req)` by default. - -- [ ] **Step 1: Write the failing test** - Add a synthetic reply module overriding `getResponseHopLimit()` to return `0`; dispatch a request and assert its captured reply has `hop_limit == 0`. -- [ ] **Step 2: Run the failing test** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because the hook does not exist. -- [ ] **Step 3: Write minimal implementation** - Declare the protected virtual hook in `MeshModule`; implement it using `routingModule->getHopLimitForResponse(req)`; after `setReplyTo(r, req)` in `sendResponse`, assign `r->hop_limit = getResponseHopLimit(req)`. -- [ ] **Step 4: Run the focused test** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. -- [ ] **Step 5: Commit** - `git add src/mesh/MeshModule.h src/mesh/MeshModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "refactor: allow modules to constrain response hop limits"`. - -### Task 2: Restrict NodeInfo broadcast discovery - -**Files:** - -- Modify: `src/modules/NodeInfoModule.h:42-56` -- Modify: `src/modules/NodeInfoModule.cpp:138-192` -- Test: `test/test_mesh_module/test_main.cpp` - -**Interfaces:** Consumes Task 1's hook and `getHopsAway()` from `src/mesh/NodeDB.h`. Produces NodeInfo replies only for direct broadcast discovery with zero hops. - -- [ ] **Step 1: Write failing tests** - Build direct (`hop_start == hop_limit`), relayed (`hop_limit < hop_start`), unknown-hop (zero hop start and no decoded bitfield), and unicast NodeInfo requests. Assert only direct broadcast qualifies and its reply hop limit is zero; assert `sendOurNodeInfo(NODENUM_BROADCAST, true)` sets `hop_limit` to zero; unicast retains the routing default. -- [ ] **Step 2: Run the failing test** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect a compilation failure because NodeInfo lacks the policy hook. -- [ ] **Step 3: Write minimal implementation** - In `sendOurNodeInfo`, set `hop_limit = 0` for a broadcast request that asks for replies. In `NodeInfoModule::allocReply`, detect an external broadcast request and return `NULL` with `ignoreRequest = true` unless `getHopsAway(*currentRequest) == 0`. Override the hook to return `0` only for a broadcast request at zero hops and otherwise call `MeshModule::getResponseHopLimit(req)`. -- [ ] **Step 4: Run focused tests** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS for direct, relayed, unknown-hop, and unicast controls. -- [ ] **Step 5: Commit** - `git add src/modules/NodeInfoModule.h src/modules/NodeInfoModule.cpp test/test_mesh_module/test_main.cpp && git commit -m "fix: limit NodeInfo discovery to direct neighbors"`. - -### Task 3: Format and validate - -**Files:** - -- Modify: only formatting reported by `trunk fmt`. - -- [ ] **Step 1: Format** - Run `trunk fmt`; expect exit 0. -- [ ] **Step 2: Run focused native tests** - Run `~/.platformio/penv/bin/python -m platformio test -e native -f test_mesh_module`; expect PASS. -- [ ] **Step 3: Run repository wrapper** - Run `./bin/run-tests.sh`; expect GREEN, or record the exact host blocker without calling it a pass. -- [ ] **Step 4: Inspect and commit** - Run `git diff --check origin/develop...HEAD`, `git status --short`, and `git log --format='%h %an <%ae> %s' origin/develop..HEAD`; then commit validation-only formatting changes if present. diff --git a/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md b/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md deleted file mode 100644 index ebe7ad51fde..00000000000 --- a/docs/superpowers/specs/2026-07-21-nodeinfo-direct-neighbor-discovery-design.md +++ /dev/null @@ -1,43 +0,0 @@ -# NodeInfo direct-neighbor discovery - -## Problem - -A reset or radio-generation change sends a broadcast NodeInfo with -`want_response`. In a large mesh every receiver can answer, and normal reply -routing gives a direct receiver a two-hop reply. That creates an avoidable -fan-out burst while the existing 12-hour suppression window can also leave a -fresh node with no immediate peer identity. - -## Design - -Send the discovery announcement as a zero-hop broadcast so the new node learns -only its direct radio neighborhood. Restrict the response as a second line of -defense: - -- Broadcast NodeInfo requests that ask for replies have `hop_limit = 0`. -- Only a broadcast `NODEINFO_APP` request received at exactly zero hops may - produce a NodeInfo response, including requests sent by older firmware. -- That response is unicast to the requester with `hop_limit = 0`; it cannot be - relayed. -- Relayed broadcasts and packets whose hop distance cannot be established do - not reply and suppress the automatic no-response NAK. -- Unicast NodeInfo exchanges, phone/API-triggered requests, and traffic - management direct-response behavior remain unchanged. - -The hop predicate uses `getHopsAway()`, which treats a decoded packet with -`hop_start == hop_limit` as a direct radio reception and rejects legacy or -malformed packets when the distance is unknown. - -## Implementation - -Add NodeInfo-specific helpers for qualifying a broadcast discovery request and -for choosing its reply hop limit. The generic `MeshModule` response path calls -the module hook after `setReplyTo()`, so only NodeInfo can replace the normal -return-path calculation with zero hops. - -## Verification - -Add native tests for direct, relayed, and unknown-hop broadcast NodeInfo -requests, plus a unicast control. Verify that only the direct broadcast reply -has a zero hop limit. Run formatting and the targeted native test suite; run -the full native wrapper when its host dependencies permit it. From 31821c41a2a5e4ae1b701a7b856b05bd900ae285 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:23:18 -0700 Subject: [PATCH 6/8] fix: preserve direct NodeInfo discovery replies --- src/modules/NodeInfoModule.cpp | 2 +- test/test_mesh_module/test_main.cpp | 42 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 11117549645..d4e283000f5 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -31,7 +31,7 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes auto p = *pptr; // Suppress replies to senders we've replied to recently (12H window) - if (mp.decoded.want_response && !isFromUs(&mp)) { + if (mp.decoded.want_response && !isFromUs(&mp) && (!isBroadcast(mp.to) || isDirectBroadcastDiscoveryRequest(mp))) { const NodeNum sender = getFrom(&mp); const uint32_t now = mp.rx_time ? mp.rx_time : getTime(); auto it = lastNodeInfoSeen.find(sender); diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index ed8217d4380..7467fd0fffc 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -3,6 +3,7 @@ #include "TestUtil.h" #include +#include "airtime.h" #include "configuration.h" #include "mesh/CryptoEngine.h" #include "mesh/MeshService.h" @@ -199,6 +200,7 @@ static MockMeshService *mockService; static MockRouter *mockRouter; static MockRoutingModule *mockRoutingModule; static NeighborInfoModule *realNeighborInfoModule; +static AirTime *testAirTime; static std::vector dispatchModules; template static T *registerDispatchModule(T *module) @@ -228,6 +230,11 @@ static void dispatch(meshtastic_PortNum port) MeshModule::callModules(request); } +static void dispatch(meshtastic_MeshPacket request) +{ + MeshModule::callModules(request); +} + } // namespace void setUp(void) @@ -238,6 +245,9 @@ void setUp(void) owner = meshtastic_User_init_zero; myNodeInfo.my_node_num = LOCAL_NODE; + testAirTime = new AirTime(); + airTime = testAirTime; + mockNodeDB = new MockNodeDB(); nodeDB = mockNodeDB; myNodeInfo.my_node_num = LOCAL_NODE; @@ -291,6 +301,10 @@ void tearDown(void) delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; + + airTime = nullptr; + delete testAirTime; + testAirTime = nullptr; } // Zero-hop broadcast (hop_limit == hop_start): should be allowed @@ -491,6 +505,33 @@ static void test_nodeInfo_unicastRequestRetainsRoutingHopLimit() TEST_ASSERT_EQUAL_UINT8(mockRoutingModule->getHopLimitForResponse(request), nodeInfo.getResponseHopLimit(request)); } +static void test_nodeInfo_rejectedBroadcastDoesNotSuppressDirectDiscovery() +{ + registerDispatchModule(new NodeInfoPolicyShim()); + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_NODEINFO_APP); + request.to = NODENUM_BROADCAST; + request.decoded.has_bitfield = true; + request.hop_start = 3; + request.hop_limit = 2; + + dispatch(request); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + + request.hop_start = 0; + request.hop_limit = 0; + request.decoded.has_bitfield = false; + dispatch(request); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + + request.decoded.has_bitfield = true; + dispatch(request); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT8(0, mockRouter->sentPackets[0].hop_limit); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + static void test_dispatch_foreignPortObserverCanSuppressNak() { auto *observer = registerDispatchModule(new ObservingIgnoreModule()); @@ -568,6 +609,7 @@ void setup() RUN_TEST(test_nodeInfo_directBroadcastDiscoveryUsesZeroHopReply); RUN_TEST(test_nodeInfo_relayedAndUnknownBroadcastDiscoveryDoNotQualify); RUN_TEST(test_nodeInfo_unicastRequestRetainsRoutingHopLimit); + RUN_TEST(test_nodeInfo_rejectedBroadcastDoesNotSuppressDirectDiscovery); RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak); RUN_TEST(test_dispatch_noResponderSendsNak); RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); From 4bb0f0c6ca70b2737ce776e587509018b40eaae3 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:45:29 -0700 Subject: [PATCH 7/8] fix: avoid suppressing rejected NodeInfo requests --- src/modules/NodeInfoModule.cpp | 26 ++++++++++++------------ test/test_mesh_module/test_main.cpp | 31 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index d4e283000f5..41b00cc4833 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -30,6 +30,19 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes auto p = *pptr; + if (p.is_licensed != owner.is_licensed) { + LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!"); + return true; + } + NodeNum sourceNum = getFrom(&mp); + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum); + // Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges + // with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT). + if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) { + LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum); + return true; + } + // Suppress replies to senders we've replied to recently (12H window) if (mp.decoded.want_response && !isFromUs(&mp) && (!isBroadcast(mp.to) || isDirectBroadcastDiscoveryRequest(mp))) { const NodeNum sender = getFrom(&mp); @@ -45,19 +58,6 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes pruneLastNodeInfoCache(); } - if (p.is_licensed != owner.is_licensed) { - LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!"); - return true; - } - NodeNum sourceNum = getFrom(&mp); - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum); - // Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges - // with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT). - if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) { - LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum); - return true; - } - // Coerce user.id to be derived from the node number snprintf(p.id, sizeof(p.id), "!%08x", getFrom(&mp)); diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index 7467fd0fffc..2be2a92cca1 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -147,8 +147,12 @@ class ZeroHopReplyModule : public SyntheticReplyModule class NodeInfoPolicyShim : public NodeInfoModule { public: + using NodeInfoModule::allocReply; using NodeInfoModule::getResponseHopLimit; + using NodeInfoModule::handleReceivedProtobuf; using NodeInfoModule::isDirectBroadcastDiscoveryRequest; + + void setCurrentRequest(const meshtastic_MeshPacket *request) { currentRequest = request; } }; class ObservingIgnoreModule : public MeshModule @@ -532,6 +536,32 @@ static void test_nodeInfo_rejectedBroadcastDoesNotSuppressDirectDiscovery() TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); } +static void test_nodeInfo_rejectedDirectRequestDoesNotSuppressDiscovery() +{ + NodeInfoPolicyShim nodeInfo; + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_NODEINFO_APP); + request.to = NODENUM_BROADCAST; + request.hop_start = 0; + request.hop_limit = 0; + request.decoded.has_bitfield = true; + + TEST_ASSERT_NOT_NULL(mockNodeDB->getOrCreateMeshNode(REMOTE_NODE)); + + meshtastic_User rejectedUser = meshtastic_User_init_zero; + rejectedUser.is_licensed = true; + TEST_ASSERT_TRUE(nodeInfo.handleReceivedProtobuf(request, &rejectedUser)); + + meshtastic_User validUser = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(nodeInfo.handleReceivedProtobuf(request, &validUser)); + + nodeInfo.setCurrentRequest(&request); + meshtastic_MeshPacket *reply = nodeInfo.allocReply(); + nodeInfo.setCurrentRequest(nullptr); + + TEST_ASSERT_NOT_NULL(reply); + packetPool.release(reply); +} + static void test_dispatch_foreignPortObserverCanSuppressNak() { auto *observer = registerDispatchModule(new ObservingIgnoreModule()); @@ -610,6 +640,7 @@ void setup() RUN_TEST(test_nodeInfo_relayedAndUnknownBroadcastDiscoveryDoNotQualify); RUN_TEST(test_nodeInfo_unicastRequestRetainsRoutingHopLimit); RUN_TEST(test_nodeInfo_rejectedBroadcastDoesNotSuppressDirectDiscovery); + RUN_TEST(test_nodeInfo_rejectedDirectRequestDoesNotSuppressDiscovery); RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak); RUN_TEST(test_dispatch_noResponderSendsNak); RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); From 2bd54dd71e204e890b9ba8900eca6ad883c669d8 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:46:54 -0700 Subject: [PATCH 8/8] fix: expose NodeInfo test module --- test/test_mesh_module/test_main.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index 2be2a92cca1..5bd7f326d88 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -152,6 +152,7 @@ class NodeInfoPolicyShim : public NodeInfoModule using NodeInfoModule::handleReceivedProtobuf; using NodeInfoModule::isDirectBroadcastDiscoveryRequest; + MeshModule *asMeshModule() { return this; } void setCurrentRequest(const meshtastic_MeshPacket *request) { currentRequest = request; } }; @@ -511,7 +512,8 @@ static void test_nodeInfo_unicastRequestRetainsRoutingHopLimit() static void test_nodeInfo_rejectedBroadcastDoesNotSuppressDirectDiscovery() { - registerDispatchModule(new NodeInfoPolicyShim()); + auto *nodeInfo = new NodeInfoPolicyShim(); + dispatchModules.push_back(nodeInfo->asMeshModule()); meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_NODEINFO_APP); request.to = NODENUM_BROADCAST; request.decoded.has_bitfield = true;