diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 5364719..7d92c95 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -212,7 +212,8 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes _vcontact_cli_cb = nullptr; memset(_vcontact_pubkey, 0, sizeof(_vcontact_pubkey)); _vcontact_lastmod = 0; - _vcontact_last_ts = 0; + memset(_vcontact_recent_ts, 0, sizeof(_vcontact_recent_ts)); + _vcontact_recent_head = 0; memset(_vcontact_pending, 0, sizeof(_vcontact_pending)); _vcontact_pending_count = 0; memset(&prefs, 0, sizeof(prefs)); @@ -1014,53 +1015,72 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) sendPacketError(ERR_UNSUPPORTED); return true; } - /* Dedupe app resends: retry attempts of the same message reuse - * its timestamp (only the attempt byte changes). Without this a - * retry re-executes the CLI line → duplicate responses. Dupes - * still get the full ack choreography so the app settles. */ uint32_t msg_timestamp = get_le32(&data[3]); - bool dup = (msg_timestamp != 0 && msg_timestamp == _vcontact_last_ts); - _vcontact_last_ts = msg_timestamp; - /* Emit the SENT response FIRST. It is the synchronous reply the - * app's send request blocks on; the CLI command below runs on this - * thread and can take a second or more (e.g. `get cad`, `advert`). - * Deferring SENT until after the command ran let the app's response - * timer fire and auto-retry the send, producing the duplicate - * replies (and the lingering "sending" state) users reported. */ + /* Extract the text now — needed both for the ack hash below and + * for the CLI call. */ + char line[MAX_TEXT_LEN + 1]; + size_t text_len = len - 13; + if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN; + memcpy(line, &data[13], text_len); + line[text_len] = '\0'; + + /* Compute the SAME delivery-ack the app expects for this exact + * (timestamp, attempt, text): sha256 over ts(4) + (attempt&3)(1) + + * text, salted with our pubkey — identical to composeMsgPacket() on + * the real send path. The old code sent a RANDOM ack, which never + * matched the value the app derives locally, so the app treated the + * loopback message as un-acked and resent it once (attempt=1) a few + * seconds later → duplicate reply. Emitting the correct SENT + + * CONFIRMED up front (before the CLI runs) settles the app at once. */ + uint8_t hbuf[5 + MAX_TEXT_LEN]; + memcpy(hbuf, &data[3], 4); /* timestamp, on-wire LE bytes */ + hbuf[4] = (data[2] & 3); /* attempt & 3 */ + memcpy(&hbuf[5], line, text_len); uint32_t ack = 0; - getRNG()->random((uint8_t *)&ack, 4); + mesh::Utils::sha256((uint8_t *)&ack, 4, hbuf, 5 + text_len, + self_id.pub_key, PUB_KEY_SIZE); if (ack == 0) ack = 1; + sendPacketSent(MSG_SEND_SENT_DIRECT, ack, 3000); + uint8_t ack_push[8]; + memcpy(ack_push, &ack, 4); + memset(&ack_push[4], 0, 4); /* trip time: 0 ms */ + sendPush(PUSH_CODE_SEND_CONFIRMED, ack_push, 8); + + /* Dedupe app resends: a retry reuses the message timestamp (only + * the attempt byte changes). The correct ack above should stop most + * retries, but a CONFIRMED lost under BLE congestion still triggers + * one — and it lands after other messages, so match against a ring + * of recent timestamps (a single last-seen slot misses it). A + * surviving retry re-acks (done above) without re-running the CLI. */ + bool dup = false; + if (msg_timestamp != 0) { + for (uint8_t i = 0; i < VCONTACT_DEDUP_SLOTS; i++) { + if (_vcontact_recent_ts[i] == msg_timestamp) { dup = true; break; } + } + if (!dup) { + _vcontact_recent_ts[_vcontact_recent_head] = msg_timestamp; + _vcontact_recent_head = + (_vcontact_recent_head + 1) % VCONTACT_DEDUP_SLOTS; + } + } - char reply[VCONTACT_CLI_REPLY_SIZE]; - reply[0] = '\0'; if (!dup) { - char line[MAX_TEXT_LEN + 1]; - size_t text_len = len - 13; - if (text_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN; - memcpy(line, &data[13], text_len); - line[text_len] = '\0'; LOG_INF("vcontact CLI: '%s'", line); - + char reply[VCONTACT_CLI_REPLY_SIZE]; + reply[0] = '\0'; if (_vcontact_cli_cb) { _vcontact_cli_cb(line, reply); } else { strcpy(reply, "CLI not available"); } + if (reply[0] != '\0') { + vcontactQueueText(reply); + } } else { LOG_DBG("vcontact CLI: dup ts=%u, re-ack only", msg_timestamp); } - - /* Delivery confirmation (loopback, 0 ms trip), then the reply. */ - uint8_t ack_push[8]; - memcpy(ack_push, &ack, 4); - memset(&ack_push[4], 0, 4); /* trip time: 0 ms */ - sendPush(PUSH_CODE_SEND_CONFIRMED, ack_push, 8); - - if (reply[0] != '\0') { - vcontactQueueText(reply); - } return true; } return false; diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 87761fb..6a99c0b 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -510,7 +510,13 @@ private: VContactCLICallback _vcontact_cli_cb; uint8_t _vcontact_pubkey[PUB_KEY_SIZE]; uint32_t _vcontact_lastmod; - uint32_t _vcontact_last_ts; /* dedupe: app resends carry the same msg timestamp */ + /* Dedupe app resends: a retry reuses the message timestamp (only the + * attempt byte changes). The retry lands several seconds later, after other + * messages, so a single last-seen slot misses it — track a ring of recent + * timestamps instead. */ + static const uint8_t VCONTACT_DEDUP_SLOTS = 16; + uint32_t _vcontact_recent_ts[VCONTACT_DEDUP_SLOTS]; + uint8_t _vcontact_recent_head; char _vcontact_pending[2][64]; /* notices buffered while the clock is invalid */ uint8_t _vcontact_pending_count; bool vcontactClockValid();