feat(ui): channel "relayed into mesh" marker (✓ on repeater echo)

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 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-14 14:18:51 +02:00
parent 219a2bba5c
commit d064a617e0
6 changed files with 94 additions and 4 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;