Add device-side DM retries with Sending x/N counter, delivered tick and Failed status in conversation view.

Tracked DM sends now retry like the app: 3 flood attempts with no path
set, or 5 with a path (4 direct, then path reset and a final flood).
Each attempt waits its own hop-aware est_timeout. Delivered resolves on
the recipient's ack for any attempt and draws a tick after the message
prefix; Failed shows once all attempts are exhausted.

Status is session-only: SD message format unchanged at v4. Wired for
keyboard, touch, CardKB and T-Watch compose plus channel share. BLE app
sends and the voice envelope path are untouched.
This commit is contained in:
pelgraine
2026-07-15 07:11:06 +10:00
parent 43b1ba082b
commit 4f28d22217
7 changed files with 284 additions and 16 deletions
+13
View File
@@ -22,6 +22,15 @@ enum class UIEventType {
ack
};
// DM send status codes (device-side tracked sends, pushed via dmSendStatus()).
// Guarded so ChannelScreen.h can carry the same definitions without a clash.
#ifndef DM_SEND_NONE
#define DM_SEND_NONE 0
#define DM_SEND_SENDING 1
#define DM_SEND_DELIVERED 2
#define DM_SEND_FAILED 3
#endif
class AbstractUITask {
protected:
mesh::MainBoard* _board;
@@ -50,6 +59,10 @@ public:
virtual void forceRefresh() {}
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
// Device-side tracked DM send status (from MyMesh retry engine).
// status is one of DM_SEND_*; attempt/total describe the retry counter.
virtual void dmSendStatus(uint32_t send_ref, uint8_t status, uint8_t attempt, uint8_t total) {}
// Mark a channel as read when BLE companion app syncs a message
virtual void markChannelReadFromBLE(uint8_t channel_idx) {}
virtual void markAllChannelsRead() {} // Companion builds: zero all unread on app connect
+123 -1
View File
@@ -510,7 +510,31 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
}
// Resolve a device-side tracked DM send when any of its attempts' acks
// arrives. Checked independently of expected_ack_table so a circular-table
// overwrite under load cannot strand a pending send.
void MyMesh::resolvePendingDMSend(uint32_t ack) {
if (ack == 0) return;
for (int i = 0; i < MAX_PENDING_DM_SENDS; i++) {
PendingDMSend& p = pending_dm[i];
if (!p.active) continue;
for (int a = 0; a < p.attempt && a < 5; a++) {
if (p.acks[a] != ack) continue;
p.active = false;
MESH_DEBUG_PRINTLN("UI: DM delivered (attempt %d/%d), ref=0x%08X",
p.attempt, p.total, p.send_ref);
if (_ui) _ui->dmSendStatus(p.send_ref, DM_SEND_DELIVERED, p.attempt, p.total);
break;
}
}
}
ContactInfo* MyMesh::processAck(const uint8_t *data) {
{
uint32_t ack;
memcpy(&ack, data, 4);
resolvePendingDMSend(ack);
}
// see if matches any in a table
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) {
if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient
@@ -946,13 +970,21 @@ void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, co
}
}
bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text,
uint32_t* out_send_ref, uint8_t* out_total) {
if (out_send_ref) *out_send_ref = 0;
if (out_total) *out_total = 0;
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
// Plan the attempt count from the path state at first transmit:
// no path -> 3 flood attempts, path set -> 4 direct then 1 flood after reset
uint8_t total = (recipient->out_path_len == OUT_PATH_UNKNOWN) ? 3 : 5;
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
uint32_t expected_ack, est_timeout;
int result = sendMessage(*recipient, timestamp, 0, text, expected_ack, est_timeout);
@@ -970,6 +1002,29 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
}
// Tracked send: allocate a retry slot so the message gets app-style
// retries and DM_SEND_* status pushes. If the table is full the send
// stays single-shot and the caller shows no status (send_ref stays 0).
if (out_send_ref) {
for (int i = 0; i < MAX_PENDING_DM_SENDS; i++) {
if (pending_dm[i].active) continue;
PendingDMSend& p = pending_dm[i];
memset(&p, 0, sizeof(p));
p.active = true;
p.attempt = 1;
p.total = total;
memcpy(p.contact_pub, recipient->id.pub_key, PUB_KEY_SIZE);
p.timestamp = timestamp;
p.send_ref = expected_ack ? expected_ack : 1;
p.acks[0] = expected_ack;
p.deadline = futureMillis(est_timeout);
StrHelper::strncpy(p.text, text, sizeof(p.text));
*out_send_ref = p.send_ref;
if (out_total) *out_total = p.total;
break;
}
}
MESH_DEBUG_PRINTLN("UI: DM sent to %s (%s), ack=0x%08X timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
expected_ack, est_timeout);
@@ -1506,6 +1561,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
app_target_ver = 0;
clearPendingReqs();
next_ack_idx = 0;
memset(pending_dm, 0, sizeof(pending_dm));
sign_data = NULL;
dirty_contacts_expiry = 0;
_nextContactSaveDue = 0;
@@ -3701,9 +3757,75 @@ void MyMesh::checkSerialInterface() {
}
}
// Retry engine for device-side tracked DM sends. Each pass: any pending send
// whose current attempt window (per-attempt est_timeout, hop-aware for direct)
// has elapsed either resends with the attempt counter bumped, or is marked
// failed once its planned attempts are exhausted. Before the final attempt of
// a 5-attempt (path set) plan, the path is reset so the last try goes flood.
void MyMesh::sweepPendingDMSends() {
for (int i = 0; i < MAX_PENDING_DM_SENDS; i++) {
PendingDMSend& p = pending_dm[i];
if (!p.active) continue;
if (!millisHasNowPassed(p.deadline)) continue;
if (p.attempt >= p.total) {
// All attempts exhausted without an ack
p.active = false;
MESH_DEBUG_PRINTLN("UI: DM failed after %d attempts, ref=0x%08X", p.attempt, p.send_ref);
if (_ui) _ui->dmSendStatus(p.send_ref, DM_SEND_FAILED, p.attempt, p.total);
continue;
}
ContactInfo* recipient = lookupContactByPubKey(p.contact_pub, PUB_KEY_SIZE);
if (recipient == NULL) {
// Contact removed mid-flight -- nothing left to send to
p.active = false;
if (_ui) _ui->dmSendStatus(p.send_ref, DM_SEND_FAILED, p.attempt, p.total);
continue;
}
uint8_t next_attempt = p.attempt + 1;
if (p.total == 5 && next_attempt == p.total) {
resetPathTo(*recipient); // final attempt: drop the set path, go flood
}
uint32_t expected_ack, est_timeout;
int result = sendMessage(*recipient, p.timestamp, next_attempt - 1, p.text,
expected_ack, est_timeout);
if (result == MSG_SEND_FAILED) {
if (next_attempt >= p.total) {
// Final attempt never left the radio -- give up now
p.active = false;
if (_ui) _ui->dmSendStatus(p.send_ref, DM_SEND_FAILED, p.attempt, p.total);
} else {
p.deadline = futureMillis(2000); // packet pool busy -- retry this attempt shortly
}
continue;
}
p.attempt = next_attempt;
p.acks[next_attempt - 1] = expected_ack;
p.deadline = futureMillis(est_timeout);
if (expected_ack) {
expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis();
expected_ack_table[next_ack_idx].ack = expected_ack;
expected_ack_table[next_ack_idx].contact = recipient;
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
}
MESH_DEBUG_PRINTLN("UI: DM retry %d/%d to %s (%s)", p.attempt, p.total, recipient->name,
result == MSG_SEND_SENT_FLOOD ? "flood" : "direct");
if (_ui) _ui->dmSendStatus(p.send_ref, DM_SEND_SENDING, p.attempt, p.total);
}
}
void MyMesh::loop() {
BaseChatMesh::loop();
// Device-side DM retry engine
sweepPendingDMSends();
// Always check USB serial for text CLI commands (independent of BLE)
checkCLIRescueCmd();
+28 -1
View File
@@ -178,7 +178,11 @@ public:
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
// Send a direct message from the UI (no BLE dependency)
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
// Pass out_send_ref to opt into a tracked send with app-style retries and
// dmSendStatus() pushes; out_total receives the planned attempt count.
// Callers that leave both NULL get the original single-transmit behaviour.
bool uiSendDirectMessage(uint32_t contact_idx, const char* text,
uint32_t* out_send_ref = NULL, uint8_t* out_total = NULL);
// Send raw binary data to a contact (PAYLOAD_TYPE_RAW_CUSTOM, direct route only)
// Used for dz0ny VE3 voice protocol: voice packets (0x56) and fetch requests (0x72)
@@ -384,6 +388,29 @@ private:
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
int next_ack_idx;
// Device-side tracked DM sends with app-style retries:
// no path set -> 3 attempts, all flood
// path set -> 5 attempts: 4 direct on the set path, then path reset
// and a final flood attempt
// Each attempt is a real transmit with the attempt counter bumped, so each
// has its own expected ack (the attempt bits are inside the ack hash).
// Session only -- a reboot abandons any in-flight sequence.
#define MAX_PENDING_DM_SENDS 4
struct PendingDMSend {
bool active;
uint8_t attempt; // attempts sent so far (1-based)
uint8_t total; // planned attempts (3 or 5)
uint8_t contact_pub[PUB_KEY_SIZE]; // recipient (idx can shift, pub key cannot)
uint32_t timestamp; // original msg timestamp, reused per attempt
uint32_t send_ref; // UI handle (= attempt-1 expected ack)
uint32_t acks[5]; // expected ack per attempt
unsigned long deadline; // futureMillis() for the current attempt
char text[MAX_TEXT_LEN + 1]; // raw text kept for resends
};
PendingDMSend pending_dm[MAX_PENDING_DM_SENDS];
void sweepPendingDMSends();
void resolvePendingDMSend(uint32_t ack);
#ifndef ADVERT_PATH_TABLE_SIZE
#define ADVERT_PATH_TABLE_SIZE 1000
#endif
+15 -6
View File
@@ -5139,11 +5139,14 @@ void handleKeyboardInput() {
snprintf(shareMsg, sizeof(shareMsg), "%s%s|%s",
MECK_CH_PREFIX, ch.name, hexSecret);
if (the_mesh.uiSendDirectMessage((uint32_t)contactIdx, shareMsg)) {
uint32_t sendRef = 0;
uint8_t sendTotal = 0;
if (the_mesh.uiSendDirectMessage((uint32_t)contactIdx, shareMsg, &sendRef, &sendTotal)) {
// Add sanitised version to DM conversation view
char displayMsg[64];
snprintf(displayMsg, sizeof(displayMsg), "Shared channel: %s", ch.name);
ui_task.addSentDM(contact.name, the_mesh.getNodePrefs()->node_name, displayMsg);
ui_task.addSentDM(contact.name, the_mesh.getNodePrefs()->node_name, displayMsg,
sendRef, sendTotal);
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "Shared with %s", contact.name);
@@ -6355,9 +6358,12 @@ void sendComposedMessage() {
if (composeDM) {
// Direct message to a specific contact
if (composeDMContactIdx >= 0) {
if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf)) {
uint32_t sendRef = 0;
uint8_t sendTotal = 0;
if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf, &sendRef, &sendTotal)) {
// Add to channel screen so sent DM appears in conversation view
ui_task.addSentDM(composeDMName, the_mesh.getNodePrefs()->node_name, utf8Buf);
ui_task.addSentDM(composeDMName, the_mesh.getNodePrefs()->node_name, utf8Buf,
sendRef, sendTotal);
ui_task.showAlert("DM sent!", 1500);
} else {
ui_task.showAlert("DM failed!", 1500);
@@ -6539,8 +6545,11 @@ void sendCardKBMessage() {
if (ckbComposeDM) {
// Direct message
if (ckbComposeDMIdx >= 0) {
if (the_mesh.uiSendDirectMessage((uint32_t)ckbComposeDMIdx, ckbComposeBuf)) {
ui_task.addSentDM(ckbComposeDMName, the_mesh.getNodePrefs()->node_name, ckbComposeBuf);
uint32_t sendRef = 0;
uint8_t sendTotal = 0;
if (the_mesh.uiSendDirectMessage((uint32_t)ckbComposeDMIdx, ckbComposeBuf, &sendRef, &sendTotal)) {
ui_task.addSentDM(ckbComposeDMName, the_mesh.getNodePrefs()->node_name, ckbComposeBuf,
sendRef, sendTotal);
ui_task.showAlert("DM sent!", 1500);
} else {
ui_task.showAlert("DM failed!", 1500);
@@ -12,6 +12,15 @@
#include <SD.h>
#endif
// DM send status codes (session only). Guarded so AbstractUITask.h can carry
// the same definitions without a clash.
#ifndef DM_SEND_NONE
#define DM_SEND_NONE 0
#define DM_SEND_SENDING 1
#define DM_SEND_DELIVERED 2
#define DM_SEND_FAILED 3
#endif
// Maximum messages to store in history
#ifndef CHANNEL_MSG_HISTORY_SIZE
#define CHANNEL_MSG_HISTORY_SIZE 300
@@ -69,6 +78,11 @@ public:
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
uint8_t scope_idx; // Region scope index for display (session only, 0xFF = unscoped). Not persisted.
// --- DM send status (session only). Not persisted to SD. ---
uint32_t send_ref; // Tracked-send handle from MyMesh (0 = untracked)
uint8_t dm_status; // DM_SEND_NONE / SENDING / DELIVERED / FAILED
uint8_t dm_attempt; // attempts sent so far (for "Sending x/N")
uint8_t dm_total; // planned attempts (3 or 5)
};
// Simple hash for DM peer matching
@@ -78,6 +92,23 @@ public:
return h;
}
// Draw a small tick (check mark) for delivered DMs using fillRect steps.
// DisplayDriver has no line primitive and the GFX fonts carry no tick
// glyph (they cover ASCII 0x20-0x7E only), so the glyph is drawn by hand.
// Inherits the current draw colour. Sized from the line height so it
// matches both the 6x8 built-in font and the 9pt faces.
static void drawDeliveredTick(DisplayDriver& display, int x, int y, int lineH) {
int s = (lineH >= 11) ? 2 : 1; // stroke thickness by font size
int baseY = y + lineH - 2 * s; // near the text baseline
// short down-stroke
display.fillRect(x, baseY - s, s, s);
display.fillRect(x + s, baseY, s, s);
// long up-stroke
display.fillRect(x + 2 * s, baseY - s, s, s);
display.fillRect(x + 3 * s, baseY - 2 * s, s, s);
display.fillRect(x + 4 * s, baseY - 3 * s, s, s);
}
private:
UITask* _task;
mesh::RTCClock* _rtc;
@@ -141,6 +172,10 @@ public:
_messages[i].dm_peer_hash = 0;
memset(_messages[i].path, 0, MSG_PATH_MAX);
_messages[i].scope_idx = 0xFF;
_messages[i].send_ref = 0;
_messages[i].dm_status = DM_SEND_NONE;
_messages[i].dm_attempt = 0;
_messages[i].dm_total = 0;
}
// Initialize unread counts
memset(_unread, 0, sizeof(_unread));
@@ -153,7 +188,8 @@ public:
// suppressUnread: if true, do not increment the unread counter for this message
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr,
bool suppressUnread = false, uint8_t scope_idx = 0xFF) {
bool suppressUnread = false, uint8_t scope_idx = 0xFF,
uint32_t send_ref = 0, uint8_t send_total = 0) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -164,6 +200,12 @@ public:
msg->snr = snr;
msg->valid = true;
msg->scope_idx = scope_idx;
// Tracked send: attempt 1 has already been transmitted by the time the
// local echo is added, so the initial state is "Sending 1/N"
msg->send_ref = send_ref;
msg->dm_status = send_ref ? DM_SEND_SENDING : DM_SEND_NONE;
msg->dm_attempt = send_ref ? 1 : 0;
msg->dm_total = send_total;
// Set DM peer hash for conversation filtering
if (channel_idx == 0xFF) {
@@ -472,6 +514,21 @@ public:
}
// -----------------------------------------------------------------------
// Update the send status of a tracked sent DM (from MyMesh via UITask).
// Returns true if a message with this send_ref was found and updated.
bool setSendStatus(uint32_t send_ref, uint8_t status, uint8_t attempt, uint8_t total) {
if (send_ref == 0) return false;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
ChannelMessage* m = &_messages[i];
if (!m->valid || m->send_ref != send_ref) continue;
m->dm_status = status;
m->dm_attempt = attempt;
m->dm_total = total;
return true;
}
return false;
}
// Per-channel history deletion
// -----------------------------------------------------------------------
@@ -617,6 +674,11 @@ public:
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
_messages[i].scope_idx = 0xFF; // region scope is session-only, not stored on SD
// DM send status is session-only, not stored on SD
_messages[i].send_ref = 0;
_messages[i].dm_status = DM_SEND_NONE;
_messages[i].dm_attempt = 0;
_messages[i].dm_total = 0;
if (_messages[i].valid) loaded++;
}
@@ -1326,6 +1388,12 @@ public:
} else {
sprintf(tmp, ">%dd ", age / 86400);
}
} else if (msg->dm_status == DM_SEND_SENDING) {
// Tracked sent DM still in flight -- show the attempt counter
sprintf(tmp, "Sending %d/%d ", msg->dm_attempt, msg->dm_total);
} else if (msg->dm_status == DM_SEND_FAILED) {
// All attempts exhausted without an ack from the recipient
sprintf(tmp, "Failed ");
} else {
int hopsDisp = (msg->path_len == 0xFF) ? 0 : (msg->path_len & 63);
// Byte mode: flood packets encode it in the upper bits of path_len.
@@ -1344,7 +1412,17 @@ public:
sprintf(tmp, "(%dh)(%db) %dd ", hopsDisp, bphDisp, age / 86400);
}
}
// Delivered: keep the standard prefix and draw a tick in a reserved
// gap straight after it (before the message text)
int statusTickX = -1;
if (!isSelected && msg->dm_status == DM_SEND_DELIVERED) {
statusTickX = display.getTextWidth(tmp);
strcat(tmp, " ");
}
display.print(tmp);
if (statusTickX >= 0) {
drawDeliveredTick(display, statusTickX + 1, y, lineHeight);
}
// DO NOT advance y - message text continues on the same line
// Message text with character wrapping and inline emoji support
+22 -6
View File
@@ -2586,10 +2586,14 @@ if (curr) curr->poll();
setCurrScreen(tw_channel);
} else if (purpose == TWatchKeyboardScreen::TWKB_DM) {
bool dmSuccess = false;
if (strlen(sendText) > 0 && the_mesh.uiSendDirectMessage((uint32_t)ctxIdx, sendText)) {
uint32_t sendRef = 0;
uint8_t sendTotal = 0;
if (strlen(sendText) > 0 &&
the_mesh.uiSendDirectMessage((uint32_t)ctxIdx, sendText, &sendRef, &sendTotal)) {
ContactInfo dmRecipient;
if (the_mesh.getContactByIdx(ctxIdx, dmRecipient)) {
addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, sendText);
addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, sendText,
sendRef, sendTotal);
}
dmSuccess = true;
}
@@ -3172,11 +3176,14 @@ void UITask::onVKBSubmit() {
if (strlen(text) == 0) break;
bool dmSuccess = false;
if (the_mesh.uiSendDirectMessage((uint32_t)idx, text)) {
uint32_t sendRef = 0;
uint8_t sendTotal = 0;
if (the_mesh.uiSendDirectMessage((uint32_t)idx, text, &sendRef, &sendTotal)) {
// Add to channel screen so sent DM appears in conversation view
ContactInfo dmRecipient;
if (the_mesh.getContactByIdx(idx, dmRecipient)) {
addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, text);
addSentDM(dmRecipient.name, the_mesh.getNodePrefs()->node_name, text,
sendRef, sendTotal);
}
dmSuccess = true;
}
@@ -3736,12 +3743,21 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
void UITask::addSentDM(const char* recipientName, const char* sender, const char* text) {
void UITask::addSentDM(const char* recipientName, const char* sender, const char* text,
uint32_t send_ref, uint8_t send_total) {
// Format as "Sender: message" and tag with recipient's peer hash
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
((ChannelScreen *) channel_screen)->addMessage(0xFF, 0, sender, formattedMsg,
nullptr, 0, recipientName);
nullptr, 0, recipientName,
false, 0xFF, send_ref, send_total);
}
void UITask::dmSendStatus(uint32_t send_ref, uint8_t status, uint8_t attempt, uint8_t total) {
if (((ChannelScreen *) channel_screen)->setSendStatus(send_ref, status, attempt, total)) {
// Repaint promptly if the user is looking at the conversation
if (isOnChannelScreen()) forceRefresh();
}
}
void UITask::markChannelReadFromBLE(uint8_t channel_idx) {
+4 -1
View File
@@ -374,7 +374,10 @@ public:
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
void addSentDM(const char* recipientName, const char* sender, const char* text);
void addSentDM(const char* recipientName, const char* sender, const char* text,
uint32_t send_ref = 0, uint8_t send_total = 0);
// DM send status push from the MyMesh retry engine
void dmSendStatus(uint32_t send_ref, uint8_t status, uint8_t attempt, uint8_t total) override;
// Mark channel as read when BLE companion app syncs messages
void markChannelReadFromBLE(uint8_t channel_idx) override;