fix(mesh): coerce coordinate traffic to the position channel on event builds (#11545)

Under USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL every coordinate packet a
client aimed at the event channel was rejected with the "Location sharing is
disabled on this channel" notification - including the phone's own location
feed. Both apps hand a GPS-less node its fix as a POSITION_APP packet
addressed to the node itself on channel 0; that packet never leaves the
device (Router::sendLocal delivers it locally) but resolved to the event
channel and was dropped before PositionModule saw it. Result: the toast on
every location tick, and nodes without a GPS never learned a position to
share on their private channel.

Position traffic now converges on the position channel - findPositionChannel(),
the first channel with non-zero on-wire precision, which is never the event
channel:

- From-us-to-us coordinate packets are exempt from the event block.
- Local coordinate sends aimed at the event channel (phone share-location,
  request-position, waypoints, any module/UI originator) are moved onto the
  position channel in Router::sendLocal and PhoneAPI instead of rejected. The
  client notification is only sent when no channel carries positions at all.
- A position request DM'd to us on the event channel is answered on the
  position channel at that channel's precision (request_id preserved, same
  reply throttle); the requester's coordinates are still not stored,
  forwarded, relayed or published. want_response from the bitfield is merged
  before the event-channel decode short-circuit so such requests are seen.
- PositionModule::sendOurPosition, positionUnchangedSinceLastSend and
  MeshService::trySendPosition use the shared helper instead of three copies
  of the same walk.

Non-event builds are unaffected: the coercion compiles out and the helper
matches the previous walk.

Tests: coverage-event-policy (test_event_channel_phone_api,
test_event_channel_router, test_position_precision, test_mqtt,
test_nexthop_routing) and the same suites with the policy off.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ben Meadors
2026-08-18 21:58:51 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 8fe246e250
commit a5fc95f774
11 changed files with 411 additions and 40 deletions
@@ -304,6 +304,9 @@ static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, uint8
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
packet.decoded.portnum = portnum;
packet.channel = channelIndex;
// A real destination: this suite never sets a node number, so a default to=0 would read as
// "to us" (getNodeNum()==0) and take the from-us-to-us loopback exemption.
packet.to = NODENUM_BROADCAST;
return packet;
}
@@ -373,7 +376,12 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel()
configureEventChannels(false, false);
meshtastic_NodeInfoLite *node =
nodeDB->getNumMeshNodes() > 1 ? nodeDB->getMeshNodeByIndex(1) : nodeDB->getOrCreateMeshNode(0x12345678);
// A persisted DB (unsandboxed host run) can hand back our own entry here; a from-us-to-us packet is
// loopback-exempt, which is not the policy under test. Insist on a remote destination.
if (node && node->num == nodeDB->getNodeNum())
node = nodeDB->getOrCreateMeshNode(0x12345678);
TEST_ASSERT_NOT_NULL(node);
TEST_ASSERT_NOT_EQUAL(nodeDB->getNodeNum(), node->num);
const NodeNum destination = node->num;
const uint8_t savedChannel = node->channel;
@@ -396,6 +404,38 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel()
#endif
}
static void test_findPositionChannel_skipsEventAndDisabledChannels()
{
// Both channels store precision 16. Under the block gate the event channel never carries
// positions, so the private one (index 1) is the position channel; otherwise index 0 wins.
configureEventChannels(false, false);
uint8_t positionChannel = 0xff;
TEST_ASSERT_TRUE(findPositionChannel(positionChannel));
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK)
TEST_ASSERT_EQUAL_UINT8(1, positionChannel);
#else
TEST_ASSERT_EQUAL_UINT8(0, positionChannel);
#endif
// Reordering follows the effective key, not the index.
configureEventChannels(true, false);
TEST_ASSERT_TRUE(findPositionChannel(positionChannel));
TEST_ASSERT_EQUAL_UINT8(0, positionChannel);
// Precision 0 everywhere: nothing to pick.
configureEventChannels(false, false);
channelFile.channels[0].settings.module_settings.position_precision = 0;
channelFile.channels[1].settings.module_settings.position_precision = 0;
channels.onConfigChanged();
TEST_ASSERT_FALSE(findPositionChannel(positionChannel));
// A disabled channel does not count even with a stored precision.
channelFile.channels[1].settings.module_settings.position_precision = 32;
channelFile.channels[1].role = meshtastic_Channel_Role_DISABLED;
channels.onConfigChanged();
TEST_ASSERT_FALSE(findPositionChannel(positionChannel));
}
static void test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored()
{
// A private channel with a full 32-byte key that is not the configured
@@ -439,6 +479,7 @@ void setup()
RUN_TEST(test_eventCoordinatePolicy_coversPortsAndExcludesPki);
RUN_TEST(test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash);
RUN_TEST(test_eventCoordinatePolicy_usesResolvedUnicastChannel);
RUN_TEST(test_findPositionChannel_skipsEventAndDisabledChannels);
RUN_TEST(test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored);
exit(UNITY_END());
}