diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index b082681..9df3eae 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -826,7 +826,7 @@ void CompanionMesh::queueLocalSentContactMessage(const ContactInfo &contact, } void CompanionMesh::queueLocalSentChannelMessage(uint8_t channel_idx, - uint32_t timestamp, const char *text) + uint32_t timestamp, const char *text, bool heard_repeat) { if (!text) return; uint8_t frame[MAX_FRAME_SIZE]; @@ -846,12 +846,13 @@ void CompanionMesh::queueLocalSentChannelMessage(uint8_t channel_idx, put_le32(&frame[i], timestamp); i += 4; - /* Channel wire-text is ": " — the same prefix that - * BaseChatMesh::sendGroupMessage applied before transmitting over LoRa. - * The phone app parses up to the first ':' as the sender, the rest as - * the body, so we need to mirror that format here or the message - * shows up empty with the sender slot empty too. */ - int n = snprintf((char *)&frame[i], sizeof(frame) - i, "%s: ", prefs.node_name); + /* Channel wire-text is ": " — the same prefix + * BaseChatMesh::sendGroupMessage applied over LoRa. Prepend a + * heard/unheard marker so the phone app can distinguish whether the + * mesh propagated our flood. */ + const char *marker = heard_repeat ? "(>>\xe2\x9c\x93) " /* (>>✓) UTF-8 */ + : "(>>\xe2\x9c\x97) "; /* (>>✗) UTF-8 */ + int n = snprintf((char *)&frame[i], sizeof(frame) - i, "%s%s: ", marker, prefs.node_name); if (n < 0) n = 0; if ((size_t)n > sizeof(frame) - i) n = sizeof(frame) - i; i += n; @@ -862,7 +863,8 @@ void CompanionMesh::queueLocalSentChannelMessage(uint8_t channel_idx, memcpy(&frame[i], text, text_len); i += text_len; - LOG_DBG("queueLocalSentChannelMessage: frame_len=%d channel_idx=%d", i, channel_idx); + LOG_DBG("queueLocalSentChannelMessage: frame_len=%d channel_idx=%d heard=%d", + i, channel_idx, (int)heard_repeat); queueOfflineMessage(frame, i); sendPush(PUSH_CODE_MSG_WAITING); } diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 3fec451..1311cdc 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -203,10 +203,13 @@ public: /** * Queue a locally-originated channel message into the BLE offline queue - * and signal MSG_WAITING. See queueLocalSentContactMessage(). + * and signal MSG_WAITING. The body is rendered as + * " : " — heard_repeat picks + * "(>>✓) " (at least one neighbor repeated the flood) vs "(>>✗) " + * (no repeats heard within the joystick UI's feedback window). */ void queueLocalSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, - const char *text); + const char *text, bool heard_repeat); /* DataStoreHost interface */ bool onContactLoaded(const ContactInfo &c) override; diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index b375b69..d397102 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -502,7 +502,7 @@ int BaseChatMesh::sendCommandData(const ContactInfo &recipient, uint32_t timesta } bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, - const char *sender_name, const char *text, int text_len) + const char *sender_name, const char *text, int text_len, uint32_t *out_hash) { uint8_t temp[5 + MAX_TEXT_LEN + 32]; memcpy(temp, ×tamp, 4); @@ -518,6 +518,15 @@ bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &chan mesh::Packet *pkt = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, temp, 5 + prefix_len + text_len); if (pkt) { + if (out_hash) { + /* Stash the FNV-1a hash for the caller and pre-register it with + * the contention tracker so heard retransmits get counted. The + * hash is content-only (payload_type + first 8 payload bytes), + * unaffected by sendFlood's later path-hash bookkeeping. */ + uint32_t h = mesh::ContentionTracker::computePacketHash32(pkt); + *out_hash = h; + getContentionTracker().trackRetransmit(h, (uint32_t)_ms->getMillis()); + } sendFloodScoped(channel, pkt); return true; } diff --git a/zephcore/helpers/BaseChatMesh.h b/zephcore/helpers/BaseChatMesh.h index bd3a9cf..22a3ee7 100644 --- a/zephcore/helpers/BaseChatMesh.h +++ b/zephcore/helpers/BaseChatMesh.h @@ -171,8 +171,13 @@ public: uint32_t &expected_ack, uint32_t &est_timeout); int sendCommandData(const ContactInfo &recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t &est_timeout); + /* @param out_hash if non-null, filled with the FNV-1a packet hash so the + * caller can later query the contention tracker for "how many neighbors + * heard and retransmitted this?" — used by the joystick UI's send-feedback + * mechanism. When provided, the packet is also pre-registered with the + * contention tracker (so heard dupes match). */ bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name, - const char *text, int text_len); + const char *text, int text_len, uint32_t *out_hash = nullptr); bool sendGroupData(mesh::GroupChannel &channel, uint8_t *path, uint8_t path_len, uint16_t data_type, const uint8_t *data, int data_len); int sendLogin(const ContactInfo &recipient, const char *password, uint32_t &est_timeout); diff --git a/zephcore/helpers/ui-joystick/joystick_defs.h b/zephcore/helpers/ui-joystick/joystick_defs.h index 59b7b5f..ff385ec 100644 --- a/zephcore/helpers/ui-joystick/joystick_defs.h +++ b/zephcore/helpers/ui-joystick/joystick_defs.h @@ -47,3 +47,11 @@ /* List view constants */ #define UI_RECENT_LIST_SIZE 4 /* max items visible in a scrollable list */ + +/* UI-only path_len status markers (no wire-format meaning beyond OUT_PATH_SENT, + * which is exposed to the BLE app). These two encode the outcome of a local + * channel send for the joystick UI's _ch_previews ring buffer: + * OUT_PATH_SENT_HEARD — at least one neighbor repeated the flood + * OUT_PATH_SENT_UNHEARD — feedback window elapsed without hearing a repeat */ +#define OUT_PATH_SENT_HEARD 0xFD +#define OUT_PATH_SENT_UNHEARD 0xFC diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index 800ae19..6a9ff1f 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -240,6 +240,106 @@ ContactInfo *JoystickUITask::tryMatchPendingAck(uint32_t ack) return nullptr; } +/* ===== Channel-send feedback ===== */ + +void JoystickUITask::pendingChannelFeedbackCb(struct k_timer *t) +{ + PendingChannelSend *slot = (PendingChannelSend *)k_timer_user_data_get(t); + if (!slot || !slot->task) return; + slot->feedback_due = true; + if (s_signal_fn) s_signal_fn(); +} + +int JoystickUITask::allocPendingChannelSlot() +{ + for (int i = 0; i < MAX_PENDING_CHANNEL_SENDS; i++) { + if (!_pending_channel_sends[i].active) return i; + } + return -1; +} + +void JoystickUITask::completePendingChannelSend(int slot_idx, bool heard) +{ + PendingChannelSend &s = _pending_channel_sends[slot_idx]; + + /* Update the local _ch_previews entry's path_len so the UI shows the + * outcome ("sent+" / "sent?"). Match by stored ring index + timestamp + * to defend against ring wrap-around. */ + if (s.preview_index >= 0 && s.preview_index < JOYSTICK_OFFLINE_QUEUE_SIZE && + _ch_previews[s.preview_index].timestamp == s.preview_timestamp) { + _ch_previews[s.preview_index].path_len = heard ? OUT_PATH_SENT_HEARD : OUT_PATH_SENT_UNHEARD; + } + + /* Mirror to BLE app now (deferred until outcome is known) with the + * heard/unheard prefix. */ + if (CompanionMesh *cm = static_cast(_mesh)) { + cm->queueLocalSentChannelMessage(s.channel_idx, s.timestamp, s.text, heard); + } + + s.active = false; + s.feedback_due = false; + _next_refresh = 0; +} + +void JoystickUITask::processPendingChannelFeedback() +{ + for (int i = 0; i < MAX_PENDING_CHANNEL_SENDS; i++) { + PendingChannelSend &s = _pending_channel_sends[i]; + if (!s.active || !s.feedback_due) continue; + s.feedback_due = false; + int dupe_count = _mesh ? _mesh->getContentionTracker().extractDupeCount(s.hash) : -1; + bool heard = (dupe_count > 0); + completePendingChannelSend(i, heard); + } +} + +bool JoystickUITask::startPendingChannel(uint8_t channel_idx, ChannelDetails &ch, + uint32_t ts, const char *text) +{ + if (!text || !_mesh) return false; + int slot_idx = allocPendingChannelSlot(); + if (slot_idx < 0) { + showAlert("Channel queue full", 1500); + return false; + } + + uint32_t pkt_hash = 0; + int text_len = (int)strlen(text); + bool ok = _mesh->sendGroupMessage(ts, ch.channel, + _prefs ? _prefs->node_name : "", text, text_len, &pkt_hash); + if (!ok) return false; + ui_signal_tx(); + + /* Local preview now (with "sent" path_len; we'll rewrite to + * SENT_HEARD/SENT_UNHEARD on feedback). */ + _ch_preview_head = (_ch_preview_head + 1) % JOYSTICK_OFFLINE_QUEUE_SIZE; + ChannelMsgPreview &p = _ch_previews[_ch_preview_head]; + strncpy(p.channel, ch.name, sizeof(p.channel) - 1); + p.channel[sizeof(p.channel) - 1] = '\0'; + strncpy(p.text, text, sizeof(p.text) - 1); + p.text[sizeof(p.text) - 1] = '\0'; + p.timestamp = ts; + p.path_len = OUT_PATH_SENT; + if (_ch_preview_count < JOYSTICK_OFFLINE_QUEUE_SIZE) _ch_preview_count++; + + PendingChannelSend &s = _pending_channel_sends[slot_idx]; + s.active = true; + s.feedback_due = false; + s.channel_idx = channel_idx; + s.preview_index = _ch_preview_head; + s.preview_timestamp = ts; + s.timestamp = ts; + s.hash = pkt_hash; + size_t tl = (size_t)text_len; + if (tl > MAX_TEXT_LEN) tl = MAX_TEXT_LEN; + memcpy(s.text, text, tl); + s.text[tl] = '\0'; + + k_timer_stop(&s.feedback_timer); + k_timer_start(&s.feedback_timer, K_MSEC(CHANNEL_FEEDBACK_WINDOW_MS), K_NO_WAIT); + return true; +} + static bool joystick_queue_initialized; #define ENTER_LONG_PRESS_MS 500 @@ -408,6 +508,15 @@ void JoystickUITask::begin(BaseChatMesh *mesh, mesh::ZephyrRTCClock *rtc, NodePr k_timer_user_data_set(&_pending_sends[i].retry_timer, &_pending_sends[i]); } + /* Channel-send feedback timers. */ + for (int i = 0; i < MAX_PENDING_CHANNEL_SENDS; i++) { + _pending_channel_sends[i].task = this; + _pending_channel_sends[i].active = false; + _pending_channel_sends[i].feedback_due = false; + k_timer_init(&_pending_channel_sends[i].feedback_timer, pendingChannelFeedbackCb, NULL); + k_timer_user_data_set(&_pending_channel_sends[i].feedback_timer, &_pending_channel_sends[i]); + } + /* Init key queue */ k_msgq_init(&_key_queue, _key_buf, sizeof(char), JOYSTICK_KEY_QUEUE_DEPTH); joystick_queue_initialized = true; @@ -655,6 +764,7 @@ void JoystickUITask::loop() /* Pending-DM retry timers may have fired in ISR. */ processPendingRetries(); + processPendingChannelFeedback(); /* Dequeue key events */ char key = 0; @@ -1019,25 +1129,10 @@ bool JoystickUITask::sendComposedMessage(const char *text) ChannelDetails ch; if (!_mesh->getChannel(_compose_channel_idx, ch)) return false; uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); - bool ok = _mesh->sendGroupMessage(ts, ch.channel, - _prefs ? _prefs->node_name : "", text, (int)strlen(text)); - if (ok) { - ui_signal_tx(); - _ch_preview_head = (_ch_preview_head + 1) % JOYSTICK_OFFLINE_QUEUE_SIZE; - ChannelMsgPreview &p = _ch_previews[_ch_preview_head]; - strncpy(p.channel, ch.name, sizeof(p.channel) - 1); - p.channel[sizeof(p.channel) - 1] = '\0'; - strncpy(p.text, text, sizeof(p.text) - 1); - p.text[sizeof(p.text) - 1] = '\0'; - p.timestamp = ts; - p.path_len = OUT_PATH_SENT; - if (_ch_preview_count < JOYSTICK_OFFLINE_QUEUE_SIZE) _ch_preview_count++; - /* Notify BLE app so it can mirror the sent message in its UI. */ - if (CompanionMesh *cm = static_cast(_mesh)) { - cm->queueLocalSentChannelMessage((uint8_t)_compose_channel_idx, ts, text); - } - } - return ok; + /* Pending-channel machinery handles broadcast, local preview, and + * the deferred BLE-app mirror (queued only after the feedback + * window decides heard / not-heard). */ + return startPendingChannel((uint8_t)_compose_channel_idx, ch, ts, text); } return false; @@ -1049,16 +1144,7 @@ bool JoystickUITask::sendChannelMessage(const char *text) ChannelDetails ch; if (!_mesh->getChannel(_compose_channel_idx, ch)) return false; uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); - bool ok = _mesh->sendGroupMessage(ts, ch.channel, - _prefs ? _prefs->node_name : "", text, (int)strlen(text)); - if (ok) { - ui_signal_tx(); - /* Notify BLE app so it can mirror the sent message in its UI. */ - if (CompanionMesh *cm = static_cast(_mesh)) { - cm->queueLocalSentChannelMessage((uint8_t)_compose_channel_idx, ts, text); - } - } - return ok; + return startPendingChannel((uint8_t)_compose_channel_idx, ch, ts, text); } bool JoystickUITask::findContactByName(const char *name, ContactInfo &contact) diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.h b/zephcore/helpers/ui-joystick/joystick_ui_task.h index 4c8cf7d..c19fd7e 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.h +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.h @@ -274,6 +274,31 @@ private: void doPendingSend(int slot_idx); void completePendingSend(int slot_idx); void processPendingRetries(); + + /* Channel-send feedback: after broadcasting a group message we wait + * CHANNEL_FEEDBACK_WINDOW_MS to see if any neighbor repeated our flood + * (queried via ContentionTracker::extractDupeCount). Outcome decides + * the body prefix in the BLE-app mirror and the path_len indicator in + * the local _ch_previews entry. */ + static const int MAX_PENDING_CHANNEL_SENDS = 4; + static constexpr uint32_t CHANNEL_FEEDBACK_WINDOW_MS = 5000; + struct PendingChannelSend { + JoystickUITask *task; + bool active; + volatile bool feedback_due; + uint8_t channel_idx; + int preview_index; /* index in _ch_previews ring to update */ + uint32_t preview_timestamp; /* extra match guard if ring wrapped */ + uint32_t timestamp; + uint32_t hash; + char text[MAX_TEXT_LEN + 1]; + struct k_timer feedback_timer; + }; + PendingChannelSend _pending_channel_sends[MAX_PENDING_CHANNEL_SENDS]; + static void pendingChannelFeedbackCb(struct k_timer *t); + int allocPendingChannelSlot(); + void processPendingChannelFeedback(); + void completePendingChannelSend(int slot_idx, bool heard); public: /* Initiate a DM with retry tracking (called by sendComposedMessage). */ bool startPendingDM(ContactInfo &recipient, uint32_t ts, const char *text); @@ -281,6 +306,10 @@ public: * Returns the recipient ContactInfo* if matched (so BaseChatMesh can do * its return-path-retry housekeeping), nullptr otherwise. */ ContactInfo *tryMatchPendingAck(uint32_t ack); + /* Initiate a channel send with feedback tracking. Returns true on + * successful broadcast; the body of the message is stored locally as + * "pending" until the feedback window expires. */ + bool startPendingChannel(uint8_t channel_idx, ChannelDetails &ch, uint32_t ts, const char *text); private: /* Discover signal cache, owned here, populated via onRepeaterDiscoverResp */ diff --git a/zephcore/helpers/ui-joystick/screens/screen_helpers.h b/zephcore/helpers/ui-joystick/screens/screen_helpers.h index ded5dd5..e751850 100644 --- a/zephcore/helpers/ui-joystick/screens/screen_helpers.h +++ b/zephcore/helpers/ui-joystick/screens/screen_helpers.h @@ -169,6 +169,14 @@ static inline void formatHopCount(uint8_t path_len, char *out, size_t out_len) snprintf(out, out_len, "sent"); return; } + if (path_len == OUT_PATH_SENT_HEARD) { + snprintf(out, out_len, "sent+"); + return; + } + if (path_len == OUT_PATH_SENT_UNHEARD) { + snprintf(out, out_len, "sent?"); + return; + } int hops = (int)(path_len & 63); if (hops <= 0) snprintf(out, out_len, "direct"); else snprintf(out, out_len, "%dh", hops); diff --git a/zephcore/include/mesh/ContentionTracker.h b/zephcore/include/mesh/ContentionTracker.h index fe87a53..dde720c 100644 --- a/zephcore/include/mesh/ContentionTracker.h +++ b/zephcore/include/mesh/ContentionTracker.h @@ -26,6 +26,14 @@ public: /* Returns true if packet matched a tracked retransmit (dupe recorded). */ bool recordDupeIfTracked(uint32_t hash32, uint32_t now_ms); + /* Capture the dupe count for the given hash and remove the entry from + * active tracking (also folds the sample into the EMA exactly once, + * same as natural finalization would have). Returns the dupe count, or + * -1 if the entry isn't found (already finalized, or never tracked). + * Used by the joystick UI to ask "did my channel send get repeated?" + * a few seconds after sending. */ + int extractDupeCount(uint32_t hash32); + /* Returns backoff_multiplier * airtime, clamped by remaining headroom. * Returns 0 when hard cap reached or backoff disabled. */ uint16_t getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const; diff --git a/zephcore/include/mesh/Mesh.h b/zephcore/include/mesh/Mesh.h index cb1f25b..27092c9 100644 --- a/zephcore/include/mesh/Mesh.h +++ b/zephcore/include/mesh/Mesh.h @@ -34,10 +34,13 @@ class Mesh : public Dispatcher { void routeDirectRecvAcks(Packet *packet, uint32_t delay_millis); DispatcherAction forwardMultipartDirect(Packet *pkt); +public: + /* Made public so the UI layer can query/extract per-packet dupe counts + * for outbound-flood feedback (joystick channel-send "heard a repeat?"). */ + class ContentionTracker& getContentionTracker() { return _contention; } + const class ContentionTracker& getContentionTracker() const { return _contention; } protected: ContentionTracker _contention; - ContentionTracker& getContentionTracker() { return _contention; } - const ContentionTracker& getContentionTracker() const { return _contention; } #ifdef CONFIG_ZEPHCORE_APC PowerController _power_ctrl; PowerController& getPowerController() { return _power_ctrl; } diff --git a/zephcore/src/ContentionTracker.cpp b/zephcore/src/ContentionTracker.cpp index b9b5d03..9f96562 100644 --- a/zephcore/src/ContentionTracker.cpp +++ b/zephcore/src/ContentionTracker.cpp @@ -99,6 +99,15 @@ bool ContentionTracker::recordDupeIfTracked(uint32_t hash32, uint32_t now_ms) return true; } +int ContentionTracker::extractDupeCount(uint32_t hash32) +{ + int idx = findEntry(hash32); + if (idx < 0) return -1; + int count = (int)_ring[idx].dupe_count; + finalizeEntry(idx); /* folds into EMA, marks inactive */ + return count; +} + uint16_t ContentionTracker::getReactiveHeadroom(uint32_t hash32, uint32_t airtime_ms) const { int idx = findEntry(hash32);