From 76a0cdf46df063914ce9d5a5984eb256ad32170b Mon Sep 17 00:00:00 2001 From: Jeff Copeland Date: Sat, 6 Jun 2026 15:25:33 +0000 Subject: [PATCH 1/5] add companion selective repeat ability (CSRA) --- examples/companion_radio/MyMesh.cpp | 91 ++++++++++++++++++++++++++++- examples/companion_radio/MyMesh.h | 4 ++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6fbb0f7428..e747f09c56 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -62,6 +62,14 @@ #define CMD_SET_DEFAULT_FLOOD_SCOPE 63 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_SEND_RAW_PACKET 65 +#define CMD_SET_CLIENT_REPEAT_MODE 66 // non-radio-params variant; SELECTIVE bypasses isValidClientRepeatFreq + +#define CLIENT_REPEAT_OFF 0 +#define CLIENT_REPEAT_ALL 1 +#define CLIENT_REPEAT_SELECTIVE 2 + +// Existing ContactInfo.flags bit; doubles as the SELECTIVE allowlist gate. +#define FLAG_FAVOURITE 0x01 // Stats sub-types for CMD_GET_STATS #define STATS_TYPE_CORE 0 @@ -480,7 +488,71 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { } bool MyMesh::allowPacketForward(const mesh::Packet* packet) { - return _prefs.client_repeat != 0; + switch (_prefs.client_repeat) { + case CLIENT_REPEAT_OFF: + return false; + case CLIENT_REPEAT_ALL: + return true; + case CLIENT_REPEAT_SELECTIVE: + // A DIRECT-routed packet only reaches allowPacketForward() after the core + // (Mesh::routeRecvPacket) has confirmed this node is the recorded next hop on its + // path: ACK / PATH-return / response packets travelling back along a path this node was + // inserted into while flood-relaying the outbound. Forward them unconditionally -- + // filtering by source here would black-hole the route, since the return's source is + // the far contact rather than the allowlisted node. + if (packet->isRouteDirect()) return true; + + // Re-flood (full-hop) when an outbound flood matches the allowlist. Returning true + // lets Mesh::routeRecvPacket() append this node's hash to the path and retransmit, + // which propagates the packet to its destination and places this node on the path so + // the destination's reverse-routed ACK comes back through it. Selectivity (the + // allowlist gate below) is what keeps this node from becoming a next-hop for + // unrelated mesh traffic. + if (shouldSelectivelyRelay(packet)) { + _selective_relays++; + return true; + } + return false; + } + return false; +} + +bool MyMesh::shouldSelectivelyRelay(const mesh::Packet* packet) { + uint8_t pt = packet->getPayloadType(); + + // Chat-type flood packets carry src_hash at payload[1]. + if (pt == PAYLOAD_TYPE_TXT_MSG || pt == PAYLOAD_TYPE_REQ || + pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_PATH) { + if (packet->payload_len < 2) return false; + uint8_t src_hash = packet->payload[1]; + int n = getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (!getContactByIdx(i, ci)) continue; + // 1-byte hash collisions (~favourite_count/256) are intrinsic to the on-wire src_hash. + if (ci.id.isHashMatch(&src_hash, 1) && (ci.flags & FLAG_FAVOURITE)) return true; + } + return false; + } + +#ifdef MAX_GROUP_CHANNELS + // Group packets carry the channel_hash at payload[0] (matches the core's group dedup key). + if (pt == PAYLOAD_TYPE_GRP_TXT || pt == PAYLOAD_TYPE_GRP_DATA) { + if (packet->payload_len < 1) return false; + uint8_t ch_hash = packet->payload[0]; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails cd; + if (!getChannel(i, cd)) continue; + // After remove_channel, the slot's name is empty but hash is sha256(zeros), + // which would otherwise produce a 1-in-256 false positive. + if (cd.name[0] == '\0') continue; + if (cd.channel.hash[0] == ch_hash) return true; + } + return false; + } +#endif + + return false; } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) { @@ -863,6 +935,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; + _selective_relays = 0; + _selective_drops = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); send_unscoped = false; @@ -1889,6 +1963,9 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], &n_recv_flood, 4); i += 4; memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; + // Extra 8 bytes; parsers that don't know about these stop at recv_errors. + memcpy(&out_frame[i], &_selective_relays, 4); i += 4; + memcpy(&out_frame[i], &_selective_drops, 4); i += 4; _serial->writeFrame(out_frame, i); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type @@ -1986,6 +2063,18 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_TABLE_FULL); } + } else if (cmd_frame[0] == CMD_SET_CLIENT_REPEAT_MODE && len >= 2) { + uint8_t mode = cmd_frame[1]; + if (mode > CLIENT_REPEAT_SELECTIVE) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (mode == CLIENT_REPEAT_ALL && !isValidClientRepeatFreq((uint32_t)(_prefs.freq * 1000))) { + // ALL mode still requires a designated repeater freq; SELECTIVE bypasses the gate + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + _prefs.client_repeat = mode; + savePrefs(); + writeOKFrame(); + } } else { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 43d3950beb..58c082edb2 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -111,6 +111,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t getExtraAckTransmitCount() const override; bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; + bool shouldSelectivelyRelay(const mesh::Packet* packet); void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis); void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override; @@ -209,6 +210,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ + uint32_t _selective_relays; // count of allowlisted floods relayed + uint32_t _selective_drops; // reserved; kept for STATS wire compat + BaseSerialInterface *_serial; AbstractUITask* _ui; From 85ca9e233a1c63c4a0d301e22e3f20134150fecd Mon Sep 17 00:00:00 2001 From: Jeff Copeland Date: Sun, 7 Jun 2026 02:30:35 +0000 Subject: [PATCH 2/5] need to also check dst_hash so ack path discovery works --- examples/companion_radio/MyMesh.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e747f09c56..171aabb7eb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -520,17 +520,24 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) { bool MyMesh::shouldSelectivelyRelay(const mesh::Packet* packet) { uint8_t pt = packet->getPayloadType(); - // Chat-type flood packets carry src_hash at payload[1]. + // Chat-type flood layout: payload[0]=dest_hash, payload[1]=src_hash. + // Match a favourite at either endpoint so both directions of its conversation relay: + // outbound floods it sends (src), and floods headed back to it (dest). When the + // favourite is shielded behind this relayer, its peer has no path home yet, so the + // returns come back as floods rather than direct routes. Without the dest match the + // return leg is dropped and the message is never ACKed. if (pt == PAYLOAD_TYPE_TXT_MSG || pt == PAYLOAD_TYPE_REQ || pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_PATH) { if (packet->payload_len < 2) return false; + uint8_t dst_hash = packet->payload[0]; uint8_t src_hash = packet->payload[1]; int n = getNumContacts(); for (int i = 0; i < n; i++) { ContactInfo ci; if (!getContactByIdx(i, ci)) continue; - // 1-byte hash collisions (~favourite_count/256) are intrinsic to the on-wire src_hash. - if (ci.id.isHashMatch(&src_hash, 1) && (ci.flags & FLAG_FAVOURITE)) return true; + // 1-byte hash collisions (~favourite_count/256) are intrinsic to the on-wire hash. + if ((ci.id.isHashMatch(&src_hash, 1) || ci.id.isHashMatch(&dst_hash, 1)) + && (ci.flags & FLAG_FAVOURITE)) return true; } return false; } From 3597e309720f4558643d39738454715afa9b3017 Mon Sep 17 00:00:00 2001 From: Jeff Copeland Date: Mon, 8 Jun 2026 01:01:11 +0000 Subject: [PATCH 3/5] only consider favorited companions and rooms, not repeaters or sensors --- examples/companion_radio/MyMesh.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 171aabb7eb..6919377c68 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -535,9 +535,12 @@ bool MyMesh::shouldSelectivelyRelay(const mesh::Packet* packet) { for (int i = 0; i < n; i++) { ContactInfo ci; if (!getContactByIdx(i, ci)) continue; + if (!(ci.flags & FLAG_FAVOURITE)) continue; + // Only relay for conversational peers; repeaters/sensors get starred for + // convenience and shouldn't pull their traffic (or their hash) into the allowlist. + if (ci.type != ADV_TYPE_CHAT && ci.type != ADV_TYPE_ROOM) continue; // 1-byte hash collisions (~favourite_count/256) are intrinsic to the on-wire hash. - if ((ci.id.isHashMatch(&src_hash, 1) || ci.id.isHashMatch(&dst_hash, 1)) - && (ci.flags & FLAG_FAVOURITE)) return true; + if (ci.id.isHashMatch(&src_hash, 1) || ci.id.isHashMatch(&dst_hash, 1)) return true; } return false; } From 38dc8c0cfd6f1bdfd90b10a29a7524292651c676 Mon Sep 17 00:00:00 2001 From: Jeff Copeland Date: Mon, 8 Jun 2026 01:08:12 +0000 Subject: [PATCH 4/5] better param bounds on CMD_SET_RADIO_PARAMS when setting repeat mode --- examples/companion_radio/MyMesh.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6919377c68..f01fdfedb0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1461,7 +1461,11 @@ void MyMesh::handleCmdFrame(size_t len) { repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ } - if (repeat && !isValidClientRepeatFreq(freq)) { + // Only CLIENT_REPEAT_ALL is tied to a designated repeater freq; SELECTIVE is + // exempt here too, matching CMD_SET_CLIENT_REPEAT_MODE. + if (repeat > CLIENT_REPEAT_SELECTIVE) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (repeat == CLIENT_REPEAT_ALL && !isValidClientRepeatFreq(freq)) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && bw <= 500000) { From a945bfef0f68237e6d16935f9828434fc2e7898d Mon Sep 17 00:00:00 2001 From: Jeff Copeland Date: Mon, 8 Jun 2026 01:14:42 +0000 Subject: [PATCH 5/5] allow room server logins via selective repeat --- examples/companion_radio/MyMesh.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f01fdfedb0..c68d318f76 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -526,11 +526,13 @@ bool MyMesh::shouldSelectivelyRelay(const mesh::Packet* packet) { // favourite is shielded behind this relayer, its peer has no path home yet, so the // returns come back as floods rather than direct routes. Without the dest match the // return leg is dropped and the message is never ACKed. - if (pt == PAYLOAD_TYPE_TXT_MSG || pt == PAYLOAD_TYPE_REQ || - pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_PATH) { - if (packet->payload_len < 2) return false; + bool chat = (pt == PAYLOAD_TYPE_TXT_MSG || pt == PAYLOAD_TYPE_REQ || + pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_PATH); + // ANON_REQ (e.g. first contact / room login to a shielded favourite) is dest_hash at + // payload[0] then the sender's full pubkey -- no 1-byte src_hash -- so dest-match only. + if (chat || pt == PAYLOAD_TYPE_ANON_REQ) { + if (packet->payload_len < (chat ? 2 : 1)) return false; uint8_t dst_hash = packet->payload[0]; - uint8_t src_hash = packet->payload[1]; int n = getNumContacts(); for (int i = 0; i < n; i++) { ContactInfo ci; @@ -540,7 +542,8 @@ bool MyMesh::shouldSelectivelyRelay(const mesh::Packet* packet) { // convenience and shouldn't pull their traffic (or their hash) into the allowlist. if (ci.type != ADV_TYPE_CHAT && ci.type != ADV_TYPE_ROOM) continue; // 1-byte hash collisions (~favourite_count/256) are intrinsic to the on-wire hash. - if (ci.id.isHashMatch(&src_hash, 1) || ci.id.isHashMatch(&dst_hash, 1)) return true; + if (ci.id.isHashMatch(&dst_hash, 1)) return true; + if (chat && ci.id.isHashMatch(&packet->payload[1], 1)) return true; } return false; }