From d064a617e077b390aeb16ee702b37437f7c54dd8 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Sun, 14 Jun 2026 14:18:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20channel=20"relayed=20into=20mesh"?= =?UTF-8?q?=20marker=20(=E2=9C=93=20on=20repeater=20echo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channels have no recipient ACK, so true delivery can't be shown. Instead reuse the heard-repeater-echo idea (same as APC's flood feedback) to mark an outgoing channel message ✓ once a repeater rebroadcast of it is heard — i.e. it was relayed into the mesh. No echo is NOT a failure (direct/0-hop neighbours never echo), so unconfirmed sends simply show no marker. - MyMesh: single-slot relay tracker independent of APC (trackRelaySend on every channel flood; hash-match in filterRecvFloodPacket gated on _relay_pending so the hot recv path is untouched otherwise; window expiry in loop just drops it). lastChannelRelaySeq() hands the seq to the UI. - AbstractUITask::onChannelRelayed() -> UITask -> QuickMsgScreen::markChannelRelayed(). - ChHistEntry gains relay_status / relay_seq; afterSend arms the marker on the exact stored entry; render shows ✓ only on ACK_OK for our own ("Me:") rows. Verified: GAT562 (SSD1306) and WioTrackerL1Eink (GxEPD) solo builds compile clean. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 3 ++ examples/companion_radio/MyMesh.cpp | 31 +++++++++++++ examples/companion_radio/MyMesh.h | 15 +++++++ .../companion_radio/ui-new/QuickMsgScreen.h | 44 +++++++++++++++++-- examples/companion_radio/ui-new/UITask.cpp | 4 ++ examples/companion_radio/ui-new/UITask.h | 1 + 6 files changed, 94 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index d46efeb1..58a54d93 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -44,6 +44,9 @@ public: // An end-to-end ACK (CRC) arrived for one of our sent messages — drives the // DM delivery-status marker. Default no-op for UIs that don't track it. virtual void onMsgAck(uint32_t ack_crc) { (void)ack_crc; } + // A repeater rebroadcast of one of our channel sends was heard (seq from + // lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker. + virtual void onChannelRelayed(uint32_t seq) { (void)seq; } // True only when a BLE central is actually bonded/connected. On a dual // (BLE+USB) interface hasConnection() is always true (USB counts), so use // this for BLE-specific UI like the pairing-PIN prompt. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1749970e..5052fd2f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -499,6 +499,16 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { apcSampleSnr(packet->getSNR()); } } + // UI relayed-into-mesh marker: same heard-echo idea, runs regardless of APC. + // Gated on _relay_pending so the hash is only computed inside the send window. + if (_relay_pending && packet->payload_len == _relay_len) { + uint8_t h[MAX_HASH_SIZE]; + packet->calculatePacketHash(h); + if (memcmp(h, _relay_hash, MAX_HASH_SIZE) == 0) { + _relay_pending = false; + if (_ui) _ui->onChannelRelayed(_relay_seq); + } + } // REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses // if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender return false; @@ -534,6 +544,7 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) { // TODO: have per-channel send_scope if (_prefs.tx_apc) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK) + trackRelaySend(pkt); // and for the UI "relayed" marker if (send_unscoped) { sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped } else { @@ -1152,6 +1163,18 @@ void MyMesh::apcTrackFloodSend(const mesh::Packet* pkt) { _apc_flood_pending = true; } +// Arm the UI "relayed into mesh" tracker for a channel send (independent of APC). +// A repeater rebroadcast heard within the window = relayed; no echo = simply not +// shown as relayed (NOT a failure — direct/0-hop neighbours never echo). +void MyMesh::trackRelaySend(const mesh::Packet* pkt) { + pkt->calculatePacketHash(_relay_hash); + _relay_len = pkt->payload_len; + _relay_deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS); + _relay_seq = (_relay_seq == 0xFFFFFFFFu) ? 1 : _relay_seq + 1; // never 0 (0 = "no relay") + _last_relay_seq = _relay_seq; + _relay_pending = true; +} + void MyMesh::onSendTimeout() { if (_prefs.tx_apc) apcOnFailure(); } @@ -1171,6 +1194,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui) { _iter_started = false; _cli_rescue = false; + _relay_pending = false; + _relay_seq = 0; + _last_relay_seq = 0; offline_queue_len = 0; app_target_ver = 0; _bot_last_dm_reply_ms = 0; @@ -2651,6 +2677,11 @@ void MyMesh::loop() { _apc_flood_pending = false; if (_prefs.tx_apc) apcOnFailure(); } + // UI relay window expired with no echo — just drop it (no echo is not a + // failure for channels; the marker simply stays "sent"). + if (_relay_pending && millisHasNowPassed(_relay_deadline)) { + _relay_pending = false; + } if (_cli_rescue) { checkCLIRescueCmd(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b1c65135..6fdd66f5 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -201,6 +201,12 @@ protected: void apcSampleSnr(float snr); void apcOnFailure(); void apcTrackFloodSend(const mesh::Packet* pkt); + void trackRelaySend(const mesh::Packet* pkt); // arm the UI relayed-into-mesh tracker +public: + // Seq of the most recently tracked channel send — the UI records it on the + // outgoing history entry so a heard echo (onChannelRelayed) can match it back. + uint32_t lastChannelRelaySeq() const { return _last_relay_seq; } +private: // DataStoreHost methods bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); } @@ -291,6 +297,15 @@ private: uint16_t _apc_flood_len; // its payload length — cheap pre-filter before hashing uint32_t _apc_flood_deadline; // echo-wait deadline for that send bool _apc_flood_pending; // a tracked flood send is awaiting its echo + // UI "relayed into mesh" tracker for channel sends — independent of APC, single + // slot (only the latest channel send is tracked). Hashing on receive only runs + // while a send is pending, so the hot flood-recv path is untouched otherwise. + uint8_t _relay_hash[MAX_HASH_SIZE]; + uint16_t _relay_len; + uint32_t _relay_deadline; + uint32_t _relay_seq; // monotonic id of the pending tracked send + uint32_t _last_relay_seq; // seq of the most recent tracked send (for the UI to record) + bool _relay_pending; bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; uint8_t app_target_ver; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 237e9c15..175b09f8 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -94,7 +94,13 @@ class QuickMsgScreen : public UIScreen { // recipient ACK exists for floods), so a missing echo is NOT shown as failure. enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; - struct ChHistEntry { uint8_t ch_idx; char text[MSG_TEXT_BUF]; uint32_t timestamp; }; + struct ChHistEntry { + uint8_t ch_idx; + char text[MSG_TEXT_BUF]; + uint32_t timestamp; + uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) + uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() + }; ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; @@ -354,7 +360,13 @@ class QuickMsgScreen : public UIScreen { _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump char entry[sizeof(ChHistEntry::text)]; snprintf(entry, sizeof(entry), "Me: %s", msg); - addChannelMsg(_sel_channel_idx, entry); + int pos = addChannelMsg(_sel_channel_idx, entry); + // Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked + // the flood it just originated and reports a heard repeater echo by seq. + if (pos >= 0) { + _hist[pos].relay_status = ACK_PENDING; + _hist[pos].relay_seq = the_mesh.lastChannelRelaySeq(); + } // After inserting sent msg at index 0, the unread index range is stale. // User is active in this channel — treat as fully read. _ch_unread[_sel_channel_idx] = 0; @@ -570,11 +582,13 @@ public: memset(_ch_unread, 0, sizeof(_ch_unread)); } - void addChannelMsg(uint8_t ch_idx, const char* text) { + // Returns the ring position the message was stored at, or -1 if rejected, so + // the outgoing path can attach a relay seq to that exact entry. + int addChannelMsg(uint8_t ch_idx, const char* text) { // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 // and was cast to uint8_t → 255). Storing such an entry would burn a ring // slot for a message that no visible channel can ever surface. - if (ch_idx >= MAX_GROUP_CHANNELS) return; + if (ch_idx >= MAX_GROUP_CHANNELS) return -1; int pos; if (_hist_count < CH_HIST_MAX) { pos = (_hist_head + _hist_count) % CH_HIST_MAX; @@ -593,9 +607,24 @@ public: _hist[pos].timestamp = rtc_clock.getCurrentTime(); strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + _hist[pos].relay_status = ACK_NONE; + _hist[pos].relay_seq = 0; bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + return pos; + } + + // Called when a repeater echo of one of our channel sends is heard. + void markChannelRelayed(uint32_t seq) { + if (seq == 0) return; + for (int i = 0; i < _hist_count; i++) { + ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; + if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { + e.relay_status = ACK_OK; + return; + } + } } void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { @@ -1073,6 +1102,13 @@ public: display.setColor(DisplayDriver::DARK); } display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); + // Channels have no recipient ACK — only show ✓ once a repeater echo + // confirms the send was relayed into the mesh; otherwise no marker + // (absence is normal, not a failure). + if (strcmp(sender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + int gx = 3 + display.getTextWidth(sender) + 3; + drawAckGlyph(display, gx, y + 1, ACK_OK); + } if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); if (portrait_expand) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ea49bb35..206e593c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1376,6 +1376,10 @@ void UITask::onMsgAck(uint32_t ack_crc) { ((QuickMsgScreen*)quick_msg)->markDmDelivered(ack_crc); } +void UITask::onChannelRelayed(uint32_t seq) { + ((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq); +} + void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 8d0223a5..c129db82 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -173,6 +173,7 @@ public: void addChannelMsg(uint8_t channel_idx, const char* text) override; void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) override; void onMsgAck(uint32_t ack_crc) override; + void onChannelRelayed(uint32_t seq) override; int getDMUnreadTotal() const; int getMsgCount() const { return _msgcount; } int getChannelUnreadCount() const;