feat(ui): DM delivery status marker (pending / delivered / failed)

Outgoing direct messages now show a small delivery glyph after "Me" in the DM
history, driven by MeshCore's existing end-to-end ACK (no protocol change):
- · pending (awaiting ACK)
- ✓ delivered (ACK matched)
- ✗ failed (no ACK by the deadline)

Plumbing:
- DmHistEntry gains ack_status / ack_tag / ack_deadline_ms (RAM only).
- sendText() captures expected_ack + est_timeout; afterSend() stores them.
- AbstractUITask::onMsgAck() virtual; MyMesh::onAckRecv routes ours-only ACKs
  to UITask -> QuickMsgScreen::markDmDelivered().
- Status evaluated lazily (pending past deadline reads as failed); no timer.
- Glyphs hand-drawn (no font has a usable check/cross), font-independent.

Sends with no path (expected_ack == 0) show no marker (can't be confirmed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-14 14:07:15 +02:00
parent 8545454f5b
commit 219a2bba5c
5 changed files with 100 additions and 7 deletions

View File

@@ -41,6 +41,9 @@ public:
}
bool hasConnection() const { return _connected; }
virtual void onBLEDisconnected() {}
// 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; }
// 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

@@ -1160,9 +1160,10 @@ void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
// onAckRecv also fires for ACKs we merely route or overhear (see Mesh.cpp), whose
// SNR belongs to an unrelated link — only feed APC for ACKs to our own sends.
// Capture the match before the base handler's processAck() clears the entry.
bool ours = _prefs.tx_apc && isAckPending(ack_crc);
bool mine = isAckPending(ack_crc);
BaseChatMesh::onAckRecv(packet, ack_crc);
if (ours) apcSampleSnr(radio_driver.getLastSNR());
if (mine && _prefs.tx_apc) apcSampleSnr(radio_driver.getLastSNR());
if (mine && _ui) _ui->onMsgAck(ack_crc); // drive DM delivery-status marker
}
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)

View File

@@ -28,6 +28,10 @@ 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().
uint32_t _last_ack_tag = 0;
uint32_t _last_ack_deadline_ms = 0;
// MSG_PICK (shared)
int _msg_sel, _msg_scroll;
int _active_msgs[QUICK_MSGS_MAX];
@@ -85,12 +89,25 @@ class QuickMsgScreen : public UIScreen {
// UTF-8 bytes) get their tail clipped.
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
// 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; };
ChHistEntry _hist[CH_HIST_MAX];
int _hist_head, _hist_count;
// DM_HIST
struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[MSG_TEXT_BUF]; uint32_t timestamp; };
struct DmHistEntry {
uint8_t prefix[4];
uint8_t outgoing;
char text[MSG_TEXT_BUF];
uint32_t timestamp;
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
};
static const int DM_HIST_MAX = 64;
DmHistEntry _dm_hist[DM_HIST_MAX];
int _dm_hist_head, _dm_hist_count;
@@ -252,7 +269,37 @@ class QuickMsgScreen : public UIScreen {
return -1;
}
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
// Small font-independent delivery marker, drawn with the current ink colour
// in a ~5x5 box. No font has a usable check/cross glyph, so draw them.
static void drawAckGlyph(DisplayDriver& d, int x, int top_y, AckState s) {
int lh = d.getLineHeight();
int y = top_y + (lh - 5) / 2;
if (y < top_y) y = top_y;
switch (s) {
case ACK_PENDING: // "·" awaiting ACK
d.fillRect(x + 1, y + 3, 2, 2);
break;
case ACK_OK: // "✓" delivered / relayed
d.fillRect(x, y + 2, 1, 1);
d.fillRect(x + 1, y + 3, 1, 1);
d.fillRect(x + 2, y + 2, 1, 1);
d.fillRect(x + 3, y + 1, 1, 1);
d.fillRect(x + 4, y, 1, 2);
break;
case ACK_FAIL: // "✗" no confirmation
for (int i = 0; i < 4; i++) {
d.fillRect(x + i, y + i, 1, 1);
d.fillRect(x + 3 - i, y + i, 1, 1);
}
break;
default: break; // ACK_NONE → nothing
}
}
// 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).
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0) {
int pos;
if (_dm_hist_count < DM_HIST_MAX) {
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
@@ -266,6 +313,17 @@ class QuickMsgScreen : public UIScreen {
_dm_hist[pos].timestamp = rtc_clock.getCurrentTime();
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
_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;
}
// Effective status for display: a pending ACK past its deadline reads as
// failed (no separate timer needed — evaluated lazily at render time).
AckState dmEffectiveStatus(const DmHistEntry& e) const {
if (e.ack_status == ACK_PENDING && (int32_t)(millis() - e.ack_deadline_ms) >= 0)
return ACK_FAIL;
return (AckState)e.ack_status;
}
int dmHistCountForContact(const uint8_t* prefix) const {
@@ -304,7 +362,7 @@ class QuickMsgScreen : public UIScreen {
_viewing_max_seen = 0;
_task->showAlert("Sent!", 600);
} else if (ok) {
storeDMMsg(_sel_contact.id.pub_key, true, msg);
storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag, _last_ack_deadline_ms);
_dm_hist_sel = 0;
_dm_hist_scroll = 0;
_phase = DM_HIST;
@@ -316,6 +374,8 @@ class QuickMsgScreen : public UIScreen {
}
bool sendText(const char* msg) {
_last_ack_tag = 0;
_last_ack_deadline_ms = 0;
if (_sending_to_channel) {
ChannelDetails ch;
if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false;
@@ -323,8 +383,15 @@ class QuickMsgScreen : public UIScreen {
the_mesh.getNodeName(), msg, strlen(msg));
} else {
uint32_t expected_ack = 0, est_timeout = 0;
return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0,
msg, expected_ack, est_timeout) > 0;
bool ok = the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0,
msg, expected_ack, est_timeout) > 0;
if (ok && expected_ack) {
_last_ack_tag = expected_ack;
// Generous margin over the base estimate so a slow multi-hop ACK isn't
// prematurely shown as failed.
_last_ack_deadline_ms = millis() + est_timeout + 4000;
}
return ok;
}
}
@@ -535,6 +602,19 @@ public:
storeDMMsg(pub_key, outgoing, text);
}
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
// Marks the matching pending outgoing DM as delivered.
void markDmDelivered(uint32_t ack_crc) {
if (ack_crc == 0) return;
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 && e.ack_tag == ack_crc) {
e.ack_status = ACK_OK;
return;
}
}
}
int getDMUnreadTotal() const {
return _task->getDMUnreadTotal();
}
@@ -846,6 +926,10 @@ public:
display.setColor(DisplayDriver::DARK);
}
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
if (e.outgoing) { // delivery marker after "Me"
int gx = 3 + display.getTextWidth(sender) + 3;
drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e));
}
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

@@ -1372,6 +1372,10 @@ int UITask::getChannelUnreadCount() const {
return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread();
}
void UITask::onMsgAck(uint32_t ack_crc) {
((QuickMsgScreen*)quick_msg)->markDmDelivered(ack_crc);
}
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text);
}

View File

@@ -172,6 +172,7 @@ public:
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 onMsgAck(uint32_t ack_crc) override;
int getDMUnreadTotal() const;
int getMsgCount() const { return _msgcount; }
int getChannelUnreadCount() const;