From bcf877496277a95f52cb3d67ecc7fe869ab2ffc9 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Sun, 14 Jun 2026 23:33:16 +0200 Subject: [PATCH] feat(ui): message delivery status (DM ACK + channel relay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end delivery indicators on the device UI, drawn next to outgoing messages and auto-scaled to the font (legible on landscape e-ink). DM (and room servers, which share the DM path): - Pending / delivered / failed marker driven by the real end-to-end ACK. Pending shows a row of dots — one per send — so auto-resend progress is visible before it resolves to ✓ / ✗. - Auto-resend: new pref dm_resend_count (0-5, default 2), Settings › Messages › Resend. A pending DM whose ACK times out is re-sent (reusing the original timestamp) until resends run out, then ✗. Driven from UITask::loop so it completes in the background, independent of screen. - Incoming dedup: a retry reuses the sender timestamp + text but carries a fresh packet hash, so addDMMsg drops copies matching prefix+ts+text. Channels (flood, no recipient ACK): - ✓ only once a repeater echo confirms the send was relayed into the mesh; no echo is normal, not a failure (no pending/fail shown). A small ring tracks a burst of sends so each matches its echo. Receive-path hashing is gated so the hot flood path is untouched when idle. Shared: - Markers shown in both the history list and the fullscreen message view. - Reusable scalable mini-icon facility in icons.h (bitmap + auto-scale); adding a new status icon is a bitmap plus one draw call. No changes to the upstream mesh library (src/). Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/AbstractUITask.h | 8 +- examples/companion_radio/DataStore.cpp | 5 + examples/companion_radio/MyMesh.cpp | 62 ++++- examples/companion_radio/MyMesh.h | 23 ++ examples/companion_radio/NodePrefs.h | 7 +- .../companion_radio/ui-new/QuickMsgScreen.h | 215 +++++++++++++++++- .../companion_radio/ui-new/SettingsScreen.h | 13 ++ examples/companion_radio/ui-new/UITask.cpp | 15 +- examples/companion_radio/ui-new/UITask.h | 4 +- examples/companion_radio/ui-new/icons.h | 46 ++++ 10 files changed, 379 insertions(+), 19 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index cafce3c2..d09b6531 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -41,6 +41,12 @@ 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; } + // 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. @@ -56,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; }; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 9dd0e22a..a2c1e82a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 32dcd706..1a781aae 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 } @@ -499,6 +499,23 @@ 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_active so the hash is only computed while a send is pending. + if (_relay_active > 0) { + uint8_t h[MAX_HASH_SIZE]; + 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 // if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender return false; @@ -534,6 +551,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 +1170,22 @@ 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) { + 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_head = (_relay_head + 1) % RELAY_RING; +} + void MyMesh::onSendTimeout() { if (_prefs.tx_apc) apcOnFailure(); } @@ -1160,9 +1194,15 @@ 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()); + // Drive the DM delivery-status marker. Note isAckPending() only covers + // app/serial-initiated sends (expected_ack_table); a DM composed on the + // device UI registers in BaseChatMesh's own ack table instead, so gating on + // `mine` here would leave every on-device DM stuck at ✗. The UI matches the + // crc against its own pending tag, so an unrelated/overheard ACK is ignored. + if (_ui) _ui->onMsgAck(ack_crc); } MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui) @@ -1170,6 +1210,11 @@ 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; + 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; app_target_ver = 0; _bot_last_dm_reply_ms = 0; @@ -1218,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; @@ -2650,6 +2696,16 @@ void MyMesh::loop() { _apc_flood_pending = false; if (_prefs.tx_apc) apcOnFailure(); } + // 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_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) { checkCLIRescueCmd(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b1c65135..66d38615 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,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. 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; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 7a75006d..4c60c8c7 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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 diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 314246b8..a7a9eebf 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -3,6 +3,7 @@ // Included by UITask.cpp after SettingsScreen.h is defined. #include "NavView.h" // navigate to a location shared inside a message +#include "icons.h" // scalable mini-icons (delivery markers) class QuickMsgScreen : public UIScreen { UITask* _task; @@ -28,6 +29,12 @@ class QuickMsgScreen : public UIScreen { int _sel_channel_idx; bool _sending_to_channel; + // 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; int _active_msgs[QUICK_MSGS_MAX]; @@ -85,12 +92,37 @@ class QuickMsgScreen : public UIScreen { // UTF-8 bytes) get their tail clipped. static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; - struct ChHistEntry { uint8_t ch_idx; char text[MSG_TEXT_BUF]; uint32_t timestamp; }; + // 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; + 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; // 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 + // 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]; int _dm_hist_head, _dm_hist_count; @@ -252,7 +284,25 @@ class QuickMsgScreen : public UIScreen { return -1; } - void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + // Delivery marker, drawn with the current ink colour and auto-scaled to the + // font (see icons.h). Pending = a row of dots, one per send (so it grows with + // each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing. + static void drawAckGlyph(DisplayDriver& d, int x, int top_y, AckState s, int sends = 1) { + switch (s) { + case ACK_PENDING: miniIconDotRow(d, x, top_y, sends); break; + case ACK_OK: miniIconDraw(d, x, top_y, ICON_CHECK, 5, 4); break; + case ACK_FAIL: miniIconDraw(d, x, top_y, ICON_CROSS, 4, 4); 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). + // 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 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; @@ -266,6 +316,23 @@ 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; + _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 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 && e.resends_left == 0 && + (int32_t)(millis() - e.ack_deadline_ms) >= 0) + return ACK_FAIL; + return (AckState)e.ack_status; } int dmHistCountForContact(const uint8_t* prefix) const { @@ -296,7 +363,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; @@ -304,7 +377,10 @@ class QuickMsgScreen : public UIScreen { _viewing_max_seen = 0; _task->showAlert("Sent!", 600); } else if (ok) { - storeDMMsg(_sel_contact.id.pub_key, true, msg); + 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; @@ -316,15 +392,27 @@ 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; - return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, - msg, expected_ack, est_timeout) > 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. + _last_ack_deadline_ms = millis() + est_timeout + 4000; + } + return ok; } } @@ -503,11 +591,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; @@ -526,13 +616,93 @@ 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; } - void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { - storeDMMsg(pub_key, outgoing, text); + // 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, + 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). + // 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; + } + } + } + + // 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 { @@ -775,6 +945,12 @@ public: int ret = _dm_fs.render(display, sender, e.text, _dm_hist_sel < dm_count - 1, _dm_hist_sel > 0); + if (e.outgoing) { // delivery marker in the (inverted) header bar + display.setColor(DisplayDriver::DARK); + drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1, + dmEffectiveStatus(e), e.attempt + 1); + display.setColor(DisplayDriver::LIGHT); + } if (_ctx_menu.active) _ctx_menu.render(display); return ret; } @@ -846,6 +1022,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), e.attempt + 1); + } if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); } if (!sel) display.setColor(DisplayDriver::LIGHT); if (portrait_expand) { @@ -903,6 +1083,12 @@ public: int ret = _fs.render(display, fsender, fmsg, _hist_sel < fs_hist_count - 1, _hist_sel > 0); + // Channels: ✓ only once a repeater echo confirms relay (see list view). + if (strcmp(fsender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) { + display.setColor(DisplayDriver::DARK); + drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK); + display.setColor(DisplayDriver::LIGHT); + } if (_ctx_menu.active) _ctx_menu.render(display); return ret; } @@ -989,6 +1175,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/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 4432374b..e9e1fb27 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -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; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 895db1cc..a869069a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1372,8 +1372,16 @@ int UITask::getChannelUnreadCount() const { return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread(); } -void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { - ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text); +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, uint32_t sender_timestamp) { + ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp); } int UITask::getDMUnreadTotal() const { @@ -1602,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(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5b945ccf..585e7231 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -171,7 +171,9 @@ 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; int getMsgCount() const { return _msgcount; } int getChannelUnreadCount() const; diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index f1806a36..c766333f 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -1,6 +1,52 @@ #pragma once #include +#include + +// ── Scalable mini-icons ────────────────────────────────────────────────────── +// Small procedural glyphs (delivery markers, etc.) authored on a 1× pixel grid +// and scaled to the current font, so they stay legible on large-font layouts +// (e.g. landscape e-ink renders text at 2×). Distinct from the XBM page icons +// further down, which are fixed-size full bitmaps drawn via drawXbm(). +// +// To add a mini-icon: +// 1. Define a bitmap: H rows top→bottom, one byte per row, bit (1<