joystick UI: channel-send heard-repeat feedback

After broadcasting a group message, wait 5s to see if any neighbor
repeated the flood and use the outcome to mark the on-device entry
and the BLE-app mirror.

  - ContentionTracker gets extractDupeCount(hash): finds the tracked
    entry, captures dupe_count, finalizes (folds into EMA, marks
    inactive), returns the count or -1.
  - BaseChatMesh::sendGroupMessage gains an optional out_hash param;
    when set, the FNV-1a packet hash is also pre-registered with the
    contention tracker so heard retransmits get counted (originated
    floods weren't tracked before, only relays).
  - Mesh::getContentionTracker() promoted to public so the UI can
    query after the feedback window.

JoystickUITask grows a 4-slot pending-channel table with per-slot
k_timer (5s one-shot). startPendingChannel() broadcasts, adds the
local _ch_previews entry with path_len = OUT_PATH_SENT, and starts
the feedback timer. The timer ISR sets a feedback_due flag; the
loop's processPendingChannelFeedback() picks it up, calls
extractDupeCount(), and rewrites the preview's path_len to
OUT_PATH_SENT_HEARD (0xFD) or OUT_PATH_SENT_UNHEARD (0xFC) — which
formatHopCount renders as "sent+" / "sent?".

The deferred BLE-app mirror queues only on outcome with body prefix
"(>>✓) " (heard) or "(>>✗) " (not heard). queueLocalSentChannelMessage
gains a heard_repeat parameter for the selection.

Both channel send entry points (sendComposedMessage's channel branch
and sendChannelMessage) now route through startPendingChannel().
This commit is contained in:
liquidraver
2026-05-21 22:24:26 +02:00
parent 8e5e54a61e
commit 03fcc60fa9
11 changed files with 213 additions and 43 deletions
+10 -8
View File
@@ -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 "<sender_name>: <body>" — 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 "<sender_name>: <body>" — 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);
}
+5 -2
View File
@@ -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-marker> <node_name>: <text>" 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;
+10 -1
View File
@@ -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, &timestamp, 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;
}
+6 -1
View File
@@ -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);
@@ -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
+115 -29
View File
@@ -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<CompanionMesh *>(_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<CompanionMesh *>(_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<CompanionMesh *>(_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)
@@ -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 */
@@ -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);
@@ -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;
+5 -2
View File
@@ -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; }
+9
View File
@@ -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);