feat(ui): DM auto-resend + incoming dedup + channel relay ring

DM auto-resend (delivery status follow-up):
- New pref dm_resend_count (0-5, default 2), Settings › Messages › Resend.
  Schema sentinel 0xC0DE0009 → 0xC0DE000A; old files clamp to the default.
- When a pending on-device DM passes its ACK deadline, tickDmResends()
  (driven from UITask::loop, so it runs in the background regardless of the
  active screen) re-sends with the next attempt# reusing the original
  timestamp, refreshing ack_tag/deadline, until resends run out → then ✗.
- dmEffectiveStatus keeps the entry pending while resends remain.

Incoming DM dedup:
- A retry reuses the sender timestamp + text but has a fresh packet hash, so
  the mesh dup-filter passes it. addDMMsg now takes sender_timestamp and drops
  copies matching prefix+timestamp+text. sender_timestamp plumbed from
  MyMesh::queueMessage through AbstractUITask/UITask.

Channel relay ring:
- Replace the single-slot relayed-into-mesh tracker with a 4-slot ring so a
  quick burst of channel sends are each matched to their repeater echo.
  Receive-path hashing still gated (now on _relay_active) so the hot flood
  path is untouched when idle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-14 15:13:29 +02:00
parent 54b53f7f0f
commit aa4bb490d0
9 changed files with 166 additions and 38 deletions

View File

@@ -62,6 +62,6 @@ public:
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void addChannelMsg(uint8_t channel_idx, const char* text) {}
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {}
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) {}
virtual void loop() = 0;
};

View File

@@ -324,6 +324,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
rd(&_prefs.tx_apc, sizeof(_prefs.tx_apc));
rd(&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count));
// These fields were appended over successive schema bumps; an older file
// can leave stray bytes here, so clamp out-of-range values back to defaults.
// Values for notif_melody_ad: 0=built-in, 1=melody1, 2=melody2, 3=none.
@@ -333,6 +334,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (_prefs.advert_sound_scope > 1) _prefs.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
if (_prefs.rx_powersave > 1) _prefs.rx_powersave = 0;
if (_prefs.tx_apc > 1) _prefs.tx_apc = 0;
// An old (0xC0DE0009) file leaves the low byte of its sentinel here (0x09),
// which is out of range — fall back to the default of 2 resends.
if (_prefs.dm_resend_count > 5) _prefs.dm_resend_count = 2;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
@@ -456,6 +460,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
file.write((uint8_t *)&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
file.write((uint8_t *)&_prefs.tx_apc, sizeof(_prefs.tx_apc));
file.write((uint8_t *)&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -481,7 +481,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
_ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key);
_ui->notify(UIEventType::contactMessage);
if (from.type == ADV_TYPE_CHAT)
_ui->addDMMsg(from.id.pub_key, false, text);
_ui->addDMMsg(from.id.pub_key, false, text, sender_timestamp);
}
#endif
}
@@ -500,13 +500,20 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
}
}
// 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) {
// Gated on _relay_active so the hash is only computed while a send is pending.
if (_relay_active > 0) {
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);
bool hashed = false;
for (int i = 0; i < RELAY_RING; i++) {
RelaySlot& s = _relay[i];
if (!s.pending || s.len != packet->payload_len) continue;
if (!hashed) { packet->calculatePacketHash(h); hashed = true; }
if (memcmp(h, s.hash, MAX_HASH_SIZE) == 0) {
s.pending = false;
_relay_active--;
if (_ui) _ui->onChannelRelayed(s.seq);
break;
}
}
}
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
@@ -1167,12 +1174,16 @@ void MyMesh::apcTrackFloodSend(const mesh::Packet* pkt) {
// 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);
RelaySlot& s = _relay[_relay_head];
if (!s.pending) _relay_active++; // overwriting an empty slot adds one pending
pkt->calculatePacketHash(s.hash);
s.len = pkt->payload_len;
s.deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
_relay_seq = (_relay_seq == 0xFFFFFFFFu) ? 1 : _relay_seq + 1; // never 0 (0 = "no relay")
s.seq = _relay_seq;
s.pending = true;
_last_relay_seq = _relay_seq;
_relay_pending = true;
_relay_head = (_relay_head + 1) % RELAY_RING;
}
void MyMesh::onSendTimeout() {
@@ -1199,7 +1210,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;
for (int i = 0; i < RELAY_RING; i++) _relay[i].pending = false;
_relay_head = 0;
_relay_active = 0;
_relay_seq = 0;
_last_relay_seq = 0;
offline_queue_len = 0;
@@ -1250,6 +1263,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.bot_reply_dm[0] = '\0';
_prefs.bot_reply_ch[0] = '\0';
_prefs.dm_show_all = 1; // show all contacts by default
_prefs.dm_resend_count = 2; // auto-resend on-device DMs twice by default
memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif));
_prefs.auto_off_secs = 15; // 15 seconds auto-off by default
_prefs.clock_hide_seconds = Features::CLOCK_HIDE_SECONDS_DEFAULT ? 1 : 0;
@@ -2682,10 +2696,15 @@ 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
// UI relay windows expired with no echo — just drop them (no echo is not a
// failure for channels; the marker simply stays "sent").
if (_relay_pending && millisHasNowPassed(_relay_deadline)) {
_relay_pending = false;
if (_relay_active > 0) {
for (int i = 0; i < RELAY_RING; i++) {
if (_relay[i].pending && millisHasNowPassed(_relay[i].deadline)) {
_relay[i].pending = false;
_relay_active--;
}
}
}
if (_cli_rescue) {

View File

@@ -297,15 +297,23 @@ 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;
// UI "relayed into mesh" tracker for channel sends — independent of APC. A small
// ring so a quick burst of channel sends are each tracked (not just the latest).
// Hashing on receive only runs while at least one slot is pending, so the hot
// flood-recv path is untouched otherwise.
static const int RELAY_RING = 4;
struct RelaySlot {
uint8_t hash[MAX_HASH_SIZE];
uint16_t len;
uint32_t deadline;
uint32_t seq;
bool pending;
};
RelaySlot _relay[RELAY_RING];
int _relay_head; // next ring slot to overwrite
int _relay_active; // number of slots currently pending (cheap gate)
uint32_t _relay_seq; // monotonic id counter for tracked sends
uint32_t _last_relay_seq; // seq of the most recent tracked send (for the UI to record)
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80];
uint8_t app_target_ver;

View File

@@ -131,11 +131,16 @@ struct NodePrefs { // persisted to file
// below the ceiling, so disabling restores the user's configured power.
uint8_t tx_apc;
// Auto-resend for on-device DMs: number of extra send attempts (0..5) made when
// no end-to-end ACK arrives before the deadline, before the delivery marker
// shows ✗. 0 = no auto-resend (single attempt). Default 2.
uint8_t dm_resend_count;
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0009;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE000A;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage

View File

@@ -28,9 +28,11 @@ class QuickMsgScreen : public UIScreen {
int _sel_channel_idx;
bool _sending_to_channel;
// Carries the just-sent DM's ACK tag + deadline from sendText() to afterSend().
// Carries the just-sent DM's ACK tag + deadline + send timestamp from
// sendText() to afterSend().
uint32_t _last_ack_tag = 0;
uint32_t _last_ack_deadline_ms = 0;
uint32_t _last_send_ts = 0;
// MSG_PICK (shared)
int _msg_sel, _msg_scroll;
@@ -113,6 +115,12 @@ class QuickMsgScreen : public UIScreen {
uint8_t ack_status; // AckState; meaningful only when outgoing
uint32_t ack_tag; // expected_ack CRC to match against onMsgAck()
uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive
// Sender-perspective message timestamp: the send timestamp for outgoing
// (reused verbatim on resend so the recipient treats it as a retry), or the
// sender_timestamp for incoming (used to dedup retried copies). 0 = unknown.
uint32_t msg_ts;
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
};
static const int DM_HIST_MAX = 64;
DmHistEntry _dm_hist[DM_HIST_MAX];
@@ -304,8 +312,11 @@ class QuickMsgScreen : public UIScreen {
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by
// ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming).
// msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp
// for incoming); resends = remaining auto-resends for an outgoing pending DM.
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0) {
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0,
uint32_t msg_ts = 0, uint8_t resends = 0) {
int pos;
if (_dm_hist_count < DM_HIST_MAX) {
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
@@ -322,12 +333,18 @@ class QuickMsgScreen : public UIScreen {
_dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE;
_dm_hist[pos].ack_tag = ack_tag;
_dm_hist[pos].ack_deadline_ms = ack_deadline_ms;
_dm_hist[pos].msg_ts = msg_ts;
_dm_hist[pos].attempt = 0;
_dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0;
}
// Effective status for display: a pending ACK past its deadline reads as
// failed (no separate timer needed — evaluated lazily at render time).
// Effective status for display. A pending ACK only reads as failed once its
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL.
AckState dmEffectiveStatus(const DmHistEntry& e) const {
if (e.ack_status == ACK_PENDING && (int32_t)(millis() - e.ack_deadline_ms) >= 0)
if (e.ack_status == ACK_PENDING && e.resends_left == 0 &&
(int32_t)(millis() - e.ack_deadline_ms) >= 0)
return ACK_FAIL;
return (AckState)e.ack_status;
}
@@ -374,7 +391,10 @@ class QuickMsgScreen : public UIScreen {
_viewing_max_seen = 0;
_task->showAlert("Sent!", 600);
} else if (ok) {
storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, _last_ack_deadline_ms);
NodePrefs* np = _task->getNodePrefs();
uint8_t resends = np ? np->dm_resend_count : 0;
storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag,
_last_ack_deadline_ms, _last_send_ts, resends);
_dm_hist_sel = 0;
_dm_hist_scroll = 0;
_phase = DM_HIST;
@@ -388,16 +408,19 @@ class QuickMsgScreen : public UIScreen {
bool sendText(const char* msg) {
_last_ack_tag = 0;
_last_ack_deadline_ms = 0;
_last_send_ts = 0;
if (_sending_to_channel) {
ChannelDetails ch;
if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false;
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
the_mesh.getNodeName(), msg, strlen(msg));
} else {
uint32_t send_ts = rtc_clock.getCurrentTime();
uint32_t expected_ack = 0, est_timeout = 0;
bool ok = the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0,
bool ok = the_mesh.sendMessage(_sel_contact, send_ts, 0,
msg, expected_ack, est_timeout) > 0;
if (ok && expected_ack) {
_last_send_ts = send_ts;
_last_ack_tag = expected_ack;
// Generous margin over the base estimate so a slow multi-hop ACK isn't
// prematurely shown as failed.
@@ -627,8 +650,20 @@ public:
}
}
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
storeDMMsg(pub_key, outgoing, text);
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0) {
// Drop retried copies of an incoming DM: a resend reuses the sender's
// timestamp and text but carries a fresh packet hash, so the mesh dup-filter
// lets it through. Match on prefix + sender_timestamp + text to suppress it.
if (!outgoing && sender_timestamp != 0) {
for (int i = 0; i < _dm_hist_count; i++) {
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (!e.outgoing && e.msg_ts == sender_timestamp &&
memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0)
return; // duplicate retry — already in history
}
}
storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0);
}
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
@@ -644,6 +679,46 @@ public:
}
}
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
int total = the_mesh.getNumContacts();
for (int i = 0; i < total; i++) {
ContactInfo c;
if (the_mesh.getContactByIdx(i, c) && memcmp(c.id.pub_key, prefix, 4) == 0) {
out = c;
return true;
}
}
return false;
}
// Background tick (called every UI loop, regardless of the active screen) that
// drives auto-resend of on-device DMs. When a pending DM passes its deadline
// with no ACK: resend with the next attempt# (reusing the original timestamp so
// the recipient dedups) while resends remain, else mark it failed (✗).
void tickDmResends() {
uint32_t now = millis();
for (int i = 0; i < _dm_hist_count; i++) {
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
if (!e.outgoing || e.ack_status != ACK_PENDING) continue;
if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting
if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; }
ContactInfo c;
if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; }
uint32_t expected_ack = 0, est_timeout = 0;
uint8_t next_attempt = e.attempt + 1;
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
expected_ack, est_timeout) > 0 && expected_ack) {
e.attempt = next_attempt;
e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC
e.ack_deadline_ms = now + est_timeout + 4000;
e.resends_left--;
} else {
e.ack_status = ACK_FAIL; // couldn't compose/send — give up
}
}
}
int getDMUnreadTotal() const {
return _task->getDMUnreadTotal();
}

View File

@@ -65,6 +65,7 @@ class SettingsScreen : public UIScreen {
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
// Messages section
SECTION_MESSAGES,
DM_RESEND,
MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4,
MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9,
Count
@@ -570,6 +571,12 @@ class SettingsScreen : public UIScreen {
display.print("Rooms");
display.setCursor(display.valCol(), y);
display.print((p && p->room_fav_only) ? "fav" : "all");
} else if (item == DM_RESEND) {
display.print("Resend");
display.setCursor(display.valCol(), y);
uint8_t n = p ? p->dm_resend_count : 0;
if (n == 0) display.print("OFF");
else { char buf[6]; snprintf(buf, sizeof(buf), "%ux", (unsigned)n); display.print(buf); }
} else if (isMsgSlot(item)) {
int slot = msgSlotIndex(item);
char label[5];
@@ -777,6 +784,12 @@ public:
_dirty = true;
return true;
}
if (_selected == DM_RESEND && p) {
int n = p->dm_resend_count;
if (right || enter) n = (n + 1) % 6; // 0..5, wraps
else if (left) n = (n + 5) % 6;
if (left || right || enter) { p->dm_resend_count = (uint8_t)n; _dirty = true; return true; }
}
if (_selected == BATT_DISPLAY && p) {
int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0;
if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT;

View File

@@ -1380,8 +1380,8 @@ 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);
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) {
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
}
int UITask::getDMUnreadTotal() const {
@@ -1610,6 +1610,9 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m
void UITask::loop() {
char c = 0;
// Background delivery: resend pending on-device DMs whose ACK timed out, and
// finalise the ✗ marker — runs regardless of which screen is active.
((QuickMsgScreen*)quick_msg)->tickDmResends();
#if UI_HAS_JOYSTICK
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
int ev = user_btn.check();

View File

@@ -171,7 +171,7 @@ public:
bool isMelodyPlaying();
void showAlert(const char* text, int duration_millis);
void addChannelMsg(uint8_t channel_idx, const char* text) override;
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) override;
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override;
void onMsgAck(uint32_t ack_crc) override;
void onChannelRelayed(uint32_t seq) override;
int getDMUnreadTotal() const;