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
+9 -19
View File
@@ -419,27 +419,17 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies)
LOG_DEBUG("Skip position ping; no fresh position since boot");
return false;
}
// Prefer the node's current channel, but fall back to the first channel with
// position enabled (matching PositionModule::sendOurPosition() behavior).
// Prefer the node's current channel, but fall back to the position channel
// (matching PositionModule::sendOurPosition() behavior).
uint8_t sendChan = node->channel;
if (getPositionPrecisionForChannel(sendChan) == 0) {
bool found = false;
for (uint8_t ch = 0; ch < 8; ++ch) {
if (getPositionPrecisionForChannel(ch) != 0) {
sendChan = ch;
found = true;
break;
}
}
if (!found) {
// No channel with position enabled: fall back to sending nodeinfo, as before.
if (nodeInfoModule) {
LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest,
wantReplies, node->channel);
nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel);
}
return false;
if (getPositionPrecisionForChannel(sendChan) == 0 && !findPositionChannel(sendChan)) {
// No channel with position enabled: fall back to sending nodeinfo, as before.
if (nodeInfoModule) {
LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest,
wantReplies, node->channel);
nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel);
}
return false;
}
LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan);
positionModule->sendOurPosition(dest, wantReplies, sendChan);
+5 -2
View File
@@ -1826,8 +1826,11 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
}
#endif
// Reject before recording duplicate or per-port cooldown state, so a blocked
// attempt cannot throttle a valid private-channel position retry.
// Coordinates aimed at the event channel go out on the position channel instead (the phone picks the
// channel it last heard the node on, which is the event channel for everyone). Only when there is no
// channel to move them to is the send rejected. Reject before recording duplicate or per-port cooldown
// state, so a blocked attempt cannot throttle a valid private-channel position retry.
coerceCoordinatePacketToPositionChannel(&p);
if (isBlockedEventCoordinatePacket(&p)) {
LOG_DEBUG("Suppress phone coordinate send on event (everyone) channel");
meshtastic_QueueStatus qs = router->getQueueStatus();
+11
View File
@@ -32,6 +32,17 @@ uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
return precision;
}
bool findPositionChannel(uint8_t &channelIndex)
{
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
if (getPositionPrecisionForChannel(i) != 0) {
channelIndex = i;
return true;
}
}
return false;
}
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
{
if (precision == 0 || precision >= 32)
+4
View File
@@ -16,6 +16,10 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
// The channel our position goes out on: the lowest index with a non-zero on-wire precision (disabled and event
// channels never qualify). Returns false when position sharing is off on every channel.
bool findPositionChannel(uint8_t &channelIndex);
// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the
// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged.
// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg.
+51 -3
View File
@@ -16,6 +16,9 @@
#include <ErriezCRC32.h>
#include <pb_decode.h>
#include <pb_encode.h>
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL
#include "modules/PositionModule.h"
#endif
#if HAS_TRAFFIC_MANAGEMENT
#endif
#if HAS_VARIABLE_HOPS
@@ -86,6 +89,11 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p)
if (p->pki_encrypted || willUsePki(p)) {
return false;
}
// From us, to us: never leaves the device (sendLocal delivers it locally). This is how the phone
// hands a GPS-less node its fix and time, so it shares nothing and must not be blocked.
if (isFromUs(p) && isToUs(p)) {
return false;
}
if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
return isCoordinatePortnum(p->decoded.portnum) && channels.isEventChannel(getEffectiveChannelIndex(p));
}
@@ -96,6 +104,33 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p)
#endif
}
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL
// A remote node's unicast position request to us. Only the reply is generated for these; the packet
// itself is still dropped by the caller.
static bool isEventChannelPositionRequestForUs(const meshtastic_MeshPacket *p)
{
return p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p->decoded.portnum == meshtastic_PortNum_POSITION_APP && p->decoded.want_response && isToUs(p) && !isFromUs(p);
}
#endif
bool coerceCoordinatePacketToPositionChannel(meshtastic_MeshPacket *p)
{
#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL
if (!isBlockedEventCoordinatePacket(p))
return false;
uint8_t positionChannel;
if (!findPositionChannel(positionChannel))
return false;
LOG_DEBUG("Coerce coordinate packet 0x%08x from event channel to position channel %u", p->id, positionChannel);
p->channel = positionChannel;
return true;
#else
(void)p;
return false;
#endif
}
bool willUsePki(const meshtastic_MeshPacket *p)
{
#if !(MESHTASTIC_EXCLUDE_PKI)
@@ -396,6 +431,11 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src)
return ERRNO_NO_INTERFACES;
} else {
// Coordinates never go out on the event channel: any local originator (phone, module, UI) that aimed
// one there is moved onto the position channel instead. Before the loopback below so the local copy
// carries the channel it will actually be sent on.
coerceCoordinatePacketToPositionChannel(p);
// If we are sending a broadcast, we also treat it as if we just received it ourself
// this allows local apps (and PCs) to see broadcasts sourced locally. Only the loopback
// handleReceived is deferred when nested; send(p) below still transmits immediately.
@@ -1029,14 +1069,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
return DecodeState::DECODE_POLICY_REJECT;
#endif
if (p->decoded.has_bitfield)
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
if (isBlockedEventCoordinatePacket(p)) {
// want_response is already merged above: a position request on the event channel is still
// answered (on the position channel) even though its coordinates are dropped.
LOG_DEBUG("Decoded coordinate packet on event channel; suppress payload logging");
return DecodeState::DECODE_SUCCESS;
}
if (p->decoded.has_bitfield)
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
/* Not actually ever used.
// Decompress if needed. jm
if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) {
@@ -1515,6 +1557,12 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src)
// Discard coordinate-bearing packets that arrive on the event ("everyone")
// channel: don't process, store in NodeDB, or rebroadcast them.
if (!skipHandle && isBlockedEventCoordinatePacket(p)) {
// A position request addressed to us is still answered, on our position channel at that
// channel's precision, so "request position" from a node that only shares the event channel
// with us resolves where positions actually live. The requester's own coordinates are
// still dropped: not stored, not forwarded to the phone, not relayed, not published.
if (isEventChannelPositionRequestForUs(p) && positionModule)
positionModule->replyOnPositionChannel(*p);
LOG_DEBUG("Drop coordinate packet on event (everyone) channel");
cancelSending(p->from, p->id);
skipHandle = true;
+4
View File
@@ -18,6 +18,10 @@ inline bool isCoordinatePortnum(meshtastic_PortNum portnum)
}
bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p);
/// Retarget a locally-originated coordinate packet that would be blocked on the event channel onto the
/// position channel (see findPositionChannel). Returns true if p->channel was changed; false when the
/// packet is not a blocked event coordinate packet or no channel carries positions.
bool coerceCoordinatePacketToPositionChannel(meshtastic_MeshPacket *p);
bool willUsePki(const meshtastic_MeshPacket *p);
/// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a
+28 -10
View File
@@ -289,6 +289,27 @@ meshtastic_MeshPacket *PositionModule::allocReply()
return reply;
}
void PositionModule::replyOnPositionChannel(const meshtastic_MeshPacket &req)
{
uint8_t positionChannel;
if (!findPositionChannel(positionChannel)) {
LOG_DEBUG("Skip position reply to 0x%08x: position sharing disabled on all channels", getFrom(&req));
return;
}
if (!service)
return;
precision = getPositionPrecisionForChannel(positionChannel);
meshtastic_MeshPacket *reply = allocReply(); // reply throttle + precision-0/no-fix guards live here
if (!reply)
return;
setReplyTo(reply, req);
reply->channel = positionChannel; // not the channel the request came in on
LOG_INFO("Reply to position request from 0x%08x on position channel %u", getFrom(&req), positionChannel);
service->sendToMesh(reply);
}
meshtastic_MeshPacket *PositionModule::allocAtakPli()
{
LOG_INFO("Send TAK V2 PLI packet");
@@ -374,12 +395,11 @@ void PositionModule::sendOurPosition()
currentGeneration = radioGeneration;
// If we changed channels, ask everyone else for their latest info
for (uint8_t channelNum = 0; channelNum < 8; channelNum++) {
if (getPositionPrecisionForChannel(channelNum) != 0) {
LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies);
sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum);
return;
}
uint8_t positionChannel;
if (findPositionChannel(positionChannel)) {
LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies);
sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel);
return;
}
LOG_INFO("Skip pos@%x:6 broadcast; position sharing disabled on all channels", localPosition.timestamp);
}
@@ -467,12 +487,10 @@ bool PositionModule::positionUnchangedSinceLastSend(const meshtastic_PositionLit
// precision). Default nodes gauge movement at that on-wire (public-clamped) resolution;
// trackers use their own configured (unclamped) precision so finer moves still count.
uint32_t precisionBits = 0;
for (uint8_t ch = 0; ch < 8; ch++) {
if (getPositionPrecisionForChannel(ch) == 0)
continue;
uint8_t ch;
if (findPositionChannel(ch)) {
precisionBits =
useConfiguredPrecision ? getPositionPrecisionForChannel(channels.getByIndex(ch)) : getPositionPrecisionForChannel(ch);
break;
}
return positionWithinPrecisionCell(selfPos.latitude_i, selfPos.longitude_i, lastGpsLatitude, lastGpsLongitude, precisionBits);
+7
View File
@@ -36,6 +36,13 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
void sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0);
void sendOurPosition();
/**
* Answer a position request that arrived on a channel we never share position on (the event channel):
* the reply goes out on the position channel at that channel's precision, tagged as a reply to req.
* Subject to the same reply throttle as allocReply(). No-op when no channel carries positions.
*/
void replyOnPositionChannel(const meshtastic_MeshPacket &req);
void handleNewPosition();
// Pure broadcast-policy helpers, split out so they're unit-testable without the module.