From 8e5e54a61ecdd8645f3acb950b7dd4dc6900bc80 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Thu, 21 May 2026 22:09:56 +0200 Subject: [PATCH] joystick UI: DM retry, force-flood fallback, BLE mirror on outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outgoing DMs now go through a 4-slot pending-send table with per-slot one-shot k_timer. On no-ACK the message retries (up to 5 attempts, 0-indexed); attempt 4 clears recipient.out_path_len + markContactsDirty so the last try forces flood and future DMs re-discover the path. ACK dispatch: CompanionMesh::processAck tries _ack_table first (phone-initiated sends), then ui_joystick_try_match_ack() for joystick- initiated sends, then falls through to connection-keepalive ACKs. BLE-app mirror is now deferred until outcome is known. The body prefix in the offline-queue frame is "(>>✓) " on delivery or "(>>✗) " on failure, replacing the previous unconditional "(>>) ". UnreadScreen's sent-entry origin prefix is updated in place via markSentEntryStatus() to "(>>+) " / "(>>X) " (ASCII for the OLED font). queueLocalSentContactMessage gains a 'delivered' parameter to pick the prefix; markContactsDirtyPublic() exposes the lazy-write trigger so the joystick's path-clear persists. --- zephcore/app/CompanionMesh.cpp | 31 ++- zephcore/app/CompanionMesh.h | 14 +- .../helpers/ui-joystick/joystick_screens.h | 4 + .../helpers/ui-joystick/joystick_ui_hooks.cpp | 9 + .../helpers/ui-joystick/joystick_ui_hooks.h | 11 + .../helpers/ui-joystick/joystick_ui_task.cpp | 188 ++++++++++++++++-- .../helpers/ui-joystick/joystick_ui_task.h | 37 ++++ .../helpers/ui-joystick/screens/messaging.cpp | 16 ++ 8 files changed, 282 insertions(+), 28 deletions(-) diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index b0e997e..b082681 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -695,6 +695,18 @@ ContactInfo *CompanionMesh::processAck(const uint8_t *data) return lookupContactByPubKey(ci.id.pub_key, PUB_KEY_SIZE); } } +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + /* Joystick UI tracks its own pending DMs; let it match before falling + * through to connection-keep-alive ACKs. The joystick handles delivery + * indicator + BLE-mirror queueing internally — we just need the matched + * contact pointer for BaseChatMesh's return-path-retry hook. */ + { + uint8_t recipient_pubkey[6]; + if (ui_joystick_try_match_ack(ack_crc, recipient_pubkey)) { + return lookupContactByPubKey(recipient_pubkey, 6); + } + } +#endif return checkConnectionsAck(data); } @@ -768,7 +780,7 @@ void CompanionMesh::queueContactMessage(const ContactInfo &contact, mesh::Packet } void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact, - uint32_t timestamp, const char *text) + uint32_t timestamp, const char *text, bool delivered) { if (!text) return; uint8_t frame[MAX_FRAME_SIZE]; @@ -790,14 +802,15 @@ void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact, i += 4; /* DM wire-text has no sender prefix (the sender is the pubkey field). - * For local-sent mirrors the phone app would otherwise show them as a - * message *from* the contact, indistinguishable from incoming. Prepend - * a visible marker so the user can see at a glance which side sent it. - * "(>>) " matches the joystick UI's own sent indicator in addPreview(). */ - static const char kSentMarker[] = "(>>) "; - size_t marker_len = sizeof(kSentMarker) - 1; + * For local-sent mirrors the phone app would otherwise show them as + * messages *from* the contact, indistinguishable from incoming. Prepend + * a visible delivery indicator so the user can tell at a glance both + * which side sent it and whether it was acked. */ + const char *marker = delivered ? "(>>\xe2\x9c\x93) " /* (>>✓) UTF-8 */ + : "(>>\xe2\x9c\x97) "; /* (>>✗) UTF-8 */ + size_t marker_len = strlen(marker); if (i + marker_len <= sizeof(frame)) { - memcpy(&frame[i], kSentMarker, marker_len); + memcpy(&frame[i], marker, marker_len); i += marker_len; } size_t text_len = strlen(text); @@ -807,7 +820,7 @@ void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact, memcpy(&frame[i], text, text_len); i += text_len; - LOG_DBG("queueLocalSentContactMessage: frame_len=%d", i); + LOG_DBG("queueLocalSentContactMessage: frame_len=%d delivered=%d", i, (int)delivered); queueOfflineMessage(frame, i); sendPush(PUSH_CODE_MSG_WAITING); } diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index c6dffda..3fec451 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -193,13 +193,13 @@ public: /** * Queue a locally-originated DM into the BLE offline queue and signal - * MSG_WAITING. The frame uses path_len = OUT_PATH_SENT (0xFE) so an - * updated phone app can render it as outbound. Older apps still see a - * message text in their inbox (possibly mis-rendered as "0xFE hops"), - * but the text content is correct so no info is lost. + * MSG_WAITING. The frame uses path_len = OUT_PATH_SENT (0xFE) and the + * body is prefixed with "(>>✓) " on delivery or "(>>✗) " on failure so + * the phone app shows a visible outcome indicator without needing + * protocol-level support. */ void queueLocalSentContactMessage(const ContactInfo &contact, uint32_t timestamp, - const char *text); + const char *text, bool delivered); /** * Queue a locally-originated channel message into the BLE offline queue @@ -371,6 +371,10 @@ public: void clearJoystickPingTag() { _pending_joystick_ping_tag = 0; } void setJoystickAdminTag(uint32_t tag) { _pending_joystick_admin_tag = tag; } void clearJoystickAdminTag() { _pending_joystick_admin_tag = 0; } + /* Force a contacts-flush schedule from outside (joystick UI clears a + * stale out_path_len during 5th-attempt fallback flood and needs the + * change to persist to /ext/contacts3). */ + void markContactsDirtyPublic() { markContactsDirty(); } private: #endif diff --git a/zephcore/helpers/ui-joystick/joystick_screens.h b/zephcore/helpers/ui-joystick/joystick_screens.h index 66d777c..5a2171d 100644 --- a/zephcore/helpers/ui-joystick/joystick_screens.h +++ b/zephcore/helpers/ui-joystick/joystick_screens.h @@ -465,6 +465,10 @@ public: * when a BLE phone is connected and will sync them). */ void addPreview(uint8_t path_len, const char *from_name, const char *msg, bool initially_read = false); + /* Update the origin prefix of an existing sent-message entry to reflect + * delivery outcome. Looks up by (timestamp, contact_name) — these are + * unique-per-second for outgoing DMs. */ + void markSentEntryStatus(uint32_t ts, const char *contact_name, bool delivered); bool getStoredMessageForSourceAt(const char *source, uint32_t ts, const char *&out_msg) const; bool getLatestStoredMessageForSource(const char *source, const char *&out_msg, uint32_t *out_ts = nullptr) const; int getContactMsgCount(const char *contact_name) const; diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index 76e381e..9de4fd8 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -225,6 +225,15 @@ extern "C" void ui_notify_radio_stats(uint32_t pkt_recv, uint32_t pkt_sent, uint } } +extern "C" bool ui_joystick_try_match_ack(uint32_t ack, uint8_t out_pubkey[6]) +{ + if (!s_task) return false; + ContactInfo *c = s_task->tryMatchPendingAck(ack); + if (!c) return false; + memcpy(out_pubkey, c->id.pub_key, 6); + return true; +} + extern "C" void ui_set_battery(uint16_t mv, uint8_t /*pct*/) { if (s_task) { diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.h b/zephcore/helpers/ui-joystick/joystick_ui_hooks.h index 9835e65..242e6c5 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.h +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.h @@ -105,6 +105,17 @@ void ui_signal_refresh(void); */ void ui_signal_tx(void); +/** + * Try to match an incoming ACK against the joystick UI's pending-DM table. + * On match: cancels the retry timer, marks the entry delivered, fires the + * deferred BLE-app mirror with the success prefix, and writes the recipient's + * 6-byte pubkey prefix to out_pubkey (caller uses it for BaseChatMesh's + * return-path-retry housekeeping). + * + * @return true if matched (out_pubkey is filled), false otherwise + */ +bool ui_joystick_try_match_ack(uint32_t ack, uint8_t out_pubkey[6]); + #ifdef __cplusplus } #endif diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index d517c6e..800ae19 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -83,6 +83,163 @@ void JoystickUITask::onDisplayStateChanged() _was_display_on = is_on; } +/* ===== Outgoing DM tracking ===== */ + +void JoystickUITask::pendingRetryTimerCb(struct k_timer *t) +{ + /* ISR — mark slot ready for retry, wake main loop. */ + PendingSend *slot = (PendingSend *)k_timer_user_data_get(t); + if (!slot || !slot->task) return; + slot->retry_due = true; + if (s_signal_fn) s_signal_fn(); +} + +int JoystickUITask::allocPendingSendSlot() +{ + for (int i = 0; i < MAX_PENDING_SENDS; i++) { + if (!_pending_sends[i].active) return i; + } + return -1; +} + +void JoystickUITask::doPendingSend(int slot_idx) +{ + PendingSend &s = _pending_sends[slot_idx]; + ContactInfo *recipient = _mesh ? _mesh->lookupContactByPubKey(s.recipient_pubkey, PUB_KEY_SIZE) : nullptr; + if (!recipient) { + s.failed = true; + completePendingSend(slot_idx); + return; + } + + /* Attempt 4 (5th, 0-indexed) — clear the saved path so this send forces + * flood. The clear persists so future DMs to this contact also re-discover. */ + if (s.attempt == MAX_SEND_ATTEMPTS - 1) { + recipient->out_path_len = OUT_PATH_UNKNOWN; + if (CompanionMesh *cm = static_cast(_mesh)) { + cm->markContactsDirtyPublic(); + } + } + + uint32_t expected_ack = 0, est_timeout = 0; + int result = _mesh->sendMessage(*recipient, s.timestamp, s.attempt, s.text, + expected_ack, est_timeout); + if (result == 0) { + /* sendMessage rejected (e.g. queue full) — back off and try again. */ + s.timeout_ms = 3000; + schedulePendingRetry(slot_idx); + return; + } + ui_signal_tx(); + s.expected_ack = expected_ack; + /* Allow the ACK round-trip (2× est_timeout + small grace) before retrying. */ + s.timeout_ms = (est_timeout > 0) ? (est_timeout * 2 + 3000) : 8000; + schedulePendingRetry(slot_idx); +} + +void JoystickUITask::schedulePendingRetry(int slot_idx) +{ + PendingSend &s = _pending_sends[slot_idx]; + k_timer_stop(&s.retry_timer); + k_timer_start(&s.retry_timer, K_MSEC(s.timeout_ms), K_NO_WAIT); +} + +void JoystickUITask::completePendingSend(int slot_idx) +{ + PendingSend &s = _pending_sends[slot_idx]; + k_timer_stop(&s.retry_timer); + bool delivered = s.delivered; + + /* Update the on-device history entry (UnreadScreen) to reflect outcome. */ + if (_unread) { + _unread->markSentEntryStatus(s.timestamp, s.recipient_name, delivered); + } + + /* Now (and only now) mirror to the BLE app with the outcome prefix. */ + ContactInfo *recipient = _mesh ? _mesh->lookupContactByPubKey(s.recipient_pubkey, PUB_KEY_SIZE) : nullptr; + if (recipient) { + if (CompanionMesh *cm = static_cast(_mesh)) { + cm->queueLocalSentContactMessage(*recipient, s.timestamp, s.text, delivered); + } + } + + s.active = false; + s.retry_due = false; + _next_refresh = 0; +} + +void JoystickUITask::processPendingRetries() +{ + for (int i = 0; i < MAX_PENDING_SENDS; i++) { + PendingSend &s = _pending_sends[i]; + if (!s.active || !s.retry_due) continue; + s.retry_due = false; + if (s.delivered) { + completePendingSend(i); + continue; + } + if (s.attempt + 1 >= MAX_SEND_ATTEMPTS) { + /* Out of retries. */ + s.failed = true; + completePendingSend(i); + continue; + } + s.attempt++; + doPendingSend(i); + } +} + +bool JoystickUITask::startPendingDM(ContactInfo &recipient, uint32_t ts, const char *text) +{ + if (!text || !_mesh) return false; + int slot_idx = allocPendingSendSlot(); + if (slot_idx < 0) { + showAlert("Send queue full", 1500); + return false; + } + + PendingSend &s = _pending_sends[slot_idx]; + s.active = true; + s.retry_due = false; + s.delivered = false; + s.failed = false; + memcpy(s.recipient_pubkey, recipient.id.pub_key, PUB_KEY_SIZE); + strncpy(s.recipient_name, recipient.name, sizeof(s.recipient_name) - 1); + s.recipient_name[sizeof(s.recipient_name) - 1] = '\0'; + s.timestamp = ts; + size_t tlen = strlen(text); + if (tlen > MAX_TEXT_LEN) tlen = MAX_TEXT_LEN; + memcpy(s.text, text, tlen); + s.text[tlen] = '\0'; + s.attempt = 0; + s.expected_ack = 0; + s.timeout_ms = 0; + + /* Local history entry now (with pending marker — addPreview uses + * OUT_PATH_SENT which becomes "(>>) ContactName:"; markSentEntryStatus() + * rewrites the marker on outcome). */ + if (_unread) { + _unread->addPreview(OUT_PATH_SENT, s.recipient_name, s.text, /*initially_read=*/true); + } + + doPendingSend(slot_idx); + return true; +} + +ContactInfo *JoystickUITask::tryMatchPendingAck(uint32_t ack) +{ + if (ack == 0) return nullptr; + for (int i = 0; i < MAX_PENDING_SENDS; i++) { + PendingSend &s = _pending_sends[i]; + if (!s.active || s.expected_ack != ack) continue; + s.delivered = true; + ContactInfo *recipient = _mesh ? _mesh->lookupContactByPubKey(s.recipient_pubkey, PUB_KEY_SIZE) : nullptr; + completePendingSend(i); + return recipient; + } + return nullptr; +} + static bool joystick_queue_initialized; #define ENTER_LONG_PRESS_MS 500 @@ -241,6 +398,16 @@ void JoystickUITask::begin(BaseChatMesh *mesh, mesh::ZephyrRTCClock *rtc, NodePr k_timer_user_data_set(&_lock_timer, this); scheduleLockTimer(); + /* Pending-DM retry timers — one per slot, one-shot, callback signals + * refresh and sets the slot's retry_due flag for the main loop. */ + for (int i = 0; i < MAX_PENDING_SENDS; i++) { + _pending_sends[i].task = this; + _pending_sends[i].active = false; + _pending_sends[i].retry_due = false; + k_timer_init(&_pending_sends[i].retry_timer, pendingRetryTimerCb, NULL); + k_timer_user_data_set(&_pending_sends[i].retry_timer, &_pending_sends[i]); + } + /* Init key queue */ k_msgq_init(&_key_queue, _key_buf, sizeof(char), JOYSTICK_KEY_QUEUE_DEPTH); joystick_queue_initialized = true; @@ -486,6 +653,9 @@ void JoystickUITask::loop() * periodic timers it owns. */ onDisplayStateChanged(); + /* Pending-DM retry timers may have fired in ISR. */ + processPendingRetries(); + /* Dequeue key events */ char key = 0; if (k_msgq_get(&_key_queue, &key, K_NO_WAIT) == 0) { @@ -829,20 +999,10 @@ bool JoystickUITask::sendComposedMessage(const char *text) ContactInfo *recipient = _mesh->lookupContactByPubKey(_compose_contact_pubkey, PUB_KEY_SIZE); if (!recipient) return false; uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); - uint32_t expected_ack = 0, est_timeout = 0; - int result = _mesh->sendMessage(*recipient, ts, 0, text, expected_ack, est_timeout); - if (result == 0) return false; - ui_signal_tx(); - if (_unread) { - /* Sent message: in history but not "unread" (user sent it). */ - _unread->addPreview(OUT_PATH_SENT, _compose_contact_name, text, - /*initially_read=*/true); - } - /* Notify BLE app so it can mirror the sent message in its UI. */ - if (CompanionMesh *cm = static_cast(_mesh)) { - cm->queueLocalSentContactMessage(*recipient, ts, text); - } - return true; + /* Goes through the pending-DM machinery: tracks expected_ack, retries + * on no-ACK (up to MAX_SEND_ATTEMPTS, last attempt forces flood), + * updates UnreadScreen + queues BLE-app mirror with outcome prefix. */ + return startPendingDM(*recipient, ts, text); } if (_compose_channel_idx == -2) { diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.h b/zephcore/helpers/ui-joystick/joystick_ui_task.h index 882ed3f..4c8cf7d 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.h +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.h @@ -246,6 +246,43 @@ private: uint8_t _pending_ping_pubkey[4]; uint32_t _pending_ping_sent_ms; + /* Outgoing DM tracking — retries on missing ACK, then forces flood as + * a last-ditch attempt, then marks failed. The BLE-app mirror is + * deferred until the outcome is known so the phone sees the message + * exactly once with a delivery indicator in the body prefix. */ + static const int MAX_PENDING_SENDS = 4; + static const uint8_t MAX_SEND_ATTEMPTS = 5; /* attempt 4 (0-indexed) forces flood */ + struct PendingSend { + JoystickUITask *task; /* back-pointer for ISR dispatch */ + bool active; + volatile bool retry_due; /* set by timer ISR, consumed in loop() */ + bool delivered; + bool failed; + uint8_t recipient_pubkey[PUB_KEY_SIZE]; + char recipient_name[32]; + uint32_t expected_ack; + uint32_t timestamp; + char text[MAX_TEXT_LEN + 1]; + uint8_t attempt; + uint32_t timeout_ms; + struct k_timer retry_timer; + }; + PendingSend _pending_sends[MAX_PENDING_SENDS]; + static void pendingRetryTimerCb(struct k_timer *t); + int allocPendingSendSlot(); + void schedulePendingRetry(int slot_idx); + void doPendingSend(int slot_idx); + void completePendingSend(int slot_idx); + void processPendingRetries(); +public: + /* Initiate a DM with retry tracking (called by sendComposedMessage). */ + bool startPendingDM(ContactInfo &recipient, uint32_t ts, const char *text); + /* Try to match an arriving ACK to a pending send. + * Returns the recipient ContactInfo* if matched (so BaseChatMesh can do + * its return-path-retry housekeeping), nullptr otherwise. */ + ContactInfo *tryMatchPendingAck(uint32_t ack); +private: + /* Discover signal cache, owned here, populated via onRepeaterDiscoverResp */ static constexpr int DISCOVER_SIGNAL_TABLE_SIZE = 16; struct DiscoverSignal { diff --git a/zephcore/helpers/ui-joystick/screens/messaging.cpp b/zephcore/helpers/ui-joystick/screens/messaging.cpp index 920f6e1..b058a45 100644 --- a/zephcore/helpers/ui-joystick/screens/messaging.cpp +++ b/zephcore/helpers/ui-joystick/screens/messaging.cpp @@ -42,6 +42,22 @@ void UnreadScreen::onExit() k_timer_stop(&_preview_timer); } +void UnreadScreen::markSentEntryStatus(uint32_t ts, const char *contact_name, bool delivered) +{ + if (!contact_name) return; + for (int i = 0; i < MAX_UNREAD_MSGS; i++) { + if (_entries[i].timestamp != ts) continue; + /* Only touch entries that look like our own sent message to that + * contact — origin currently starts with "(>>" (set by addPreview + * when path_len == OUT_PATH_SENT). */ + if (strncmp(_entries[i].origin, "(>>", 3) != 0) continue; + if (!strstr(_entries[i].origin, contact_name)) continue; + snprintf(_entries[i].origin, sizeof(_entries[i].origin), + "(>>%s) %s:", delivered ? "+" : "X", contact_name); + return; + } +} + void UnreadScreen::normalizeUnreadState() { if (_entry_count < 0) _entry_count = 0;