joystick UI: DM retry, force-flood fallback, BLE mirror on outcome

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.
This commit is contained in:
liquidraver
2026-05-21 22:09:56 +02:00
parent 681ade41fa
commit 8e5e54a61e
8 changed files with 282 additions and 28 deletions
+22 -9
View File
@@ -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);
}
+9 -5
View File
@@ -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
@@ -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;
@@ -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) {
@@ -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
+174 -14
View File
@@ -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<CompanionMesh *>(_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<CompanionMesh *>(_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<CompanionMesh *>(_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) {
@@ -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 {
@@ -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;