From 3bf29b5264fb8c69678206ce4fb262ca0f512ef6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 7 Aug 2026 13:31:04 -0700 Subject: [PATCH] Fix room message timestamps for legacy servers --- examples/companion_radio/MyMesh.cpp | 20 +++++ examples/companion_radio/MyMesh.h | 6 ++ src/helpers/MessageTimestampCache.h | 76 +++++++++++++++++++ .../test_message_timestamp_cache.cpp | 75 ++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/helpers/MessageTimestampCache.h create mode 100644 test/test_message_timestamp_cache/test_message_timestamp_cache.cpp diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b03334c1c9..753642325f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1100,7 +1100,27 @@ void MyMesh::handleCmdFrame(size_t len) { result = sendCommandData(*recipient, msg_timestamp, attempt, text, est_timeout); expected_ack = 0; // no Ack expected } else { + const uint32_t app_timestamp = msg_timestamp; + const bool is_room_message = recipient->type == ADV_TYPE_ROOM; + uint8_t message_fingerprint[MAX_HASH_SIZE]; + if (is_room_message) { + mesh::Utils::sha256(message_fingerprint, sizeof(message_fingerprint), + recipient->id.pub_key, PUB_KEY_SIZE, + (const uint8_t*)text, strlen(text)); + if (!room_message_timestamps.find(message_fingerprint, app_timestamp, + &msg_timestamp)) { + // Older room servers compare posts with login and keep-alive + // timestamps, which already come from this monotonic clock. + msg_timestamp = getRTCClock()->getCurrentTimeUnique(); + } + } result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); + if (result != MSG_SEND_FAILED && is_room_message) { + // Preserve the translated timestamp across application retries so + // the room can ACK the retry without storing a duplicate post. + room_message_timestamps.remember(message_fingerprint, app_timestamp, + msg_timestamp); + } } // TODO: add expected ACK to table if (result == MSG_SEND_FAILED) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d95b073fb7..25be13a52f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +64,10 @@ #define OFFLINE_QUEUE_SIZE 16 #endif +#ifndef ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE +#define ROOM_MESSAGE_TIMESTAMP_CACHE_SIZE 16 +#endif + #ifndef BLE_NAME_PREFIX #define BLE_NAME_PREFIX "MeshCore-" #endif @@ -253,6 +258,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; + mesh::MessageTimestampCache room_message_timestamps; #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table diff --git a/src/helpers/MessageTimestampCache.h b/src/helpers/MessageTimestampCache.h new file mode 100644 index 0000000000..50b8287963 --- /dev/null +++ b/src/helpers/MessageTimestampCache.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { + +// Maps an application message identity to the timestamp used on air. This +// lets retransmissions keep the same timestamp even when the sender needs to +// translate timestamps between clock sources. +template +class MessageTimestampCache { +public: + MessageTimestampCache() { clear(); } + + bool find(const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp, + uint32_t* mapped_timestamp = NULL) const { + if (fingerprint == NULL) return false; + + for (size_t i = 0; i < ENTRY_COUNT; i++) { + const Entry& entry = entries_[i]; + if (entry.valid && entry.source_timestamp == source_timestamp + && memcmp(entry.fingerprint, fingerprint, MAX_HASH_SIZE) == 0) { + if (mapped_timestamp != NULL) { + *mapped_timestamp = entry.mapped_timestamp; + } + return true; + } + } + return false; + } + + bool remember(const uint8_t fingerprint[MAX_HASH_SIZE], uint32_t source_timestamp, + uint32_t mapped_timestamp) { + if (fingerprint == NULL) return false; + + for (size_t i = 0; i < ENTRY_COUNT; i++) { + Entry& entry = entries_[i]; + if (entry.valid && entry.source_timestamp == source_timestamp + && memcmp(entry.fingerprint, fingerprint, MAX_HASH_SIZE) == 0) { + entry.mapped_timestamp = mapped_timestamp; + return true; + } + } + + Entry& entry = entries_[next_entry_]; + memcpy(entry.fingerprint, fingerprint, MAX_HASH_SIZE); + entry.source_timestamp = source_timestamp; + entry.mapped_timestamp = mapped_timestamp; + entry.valid = true; + next_entry_ = (next_entry_ + 1) % ENTRY_COUNT; + return true; + } + + void clear() { + memset(entries_, 0, sizeof(entries_)); + next_entry_ = 0; + } + +private: + static_assert(ENTRY_COUNT > 0, "MessageTimestampCache needs at least one entry"); + + struct Entry { + uint8_t fingerprint[MAX_HASH_SIZE]; + uint32_t source_timestamp; + uint32_t mapped_timestamp; + bool valid; + }; + + Entry entries_[ENTRY_COUNT]; + size_t next_entry_; +}; + +} // namespace mesh diff --git a/test/test_message_timestamp_cache/test_message_timestamp_cache.cpp b/test/test_message_timestamp_cache/test_message_timestamp_cache.cpp new file mode 100644 index 0000000000..327a87d12c --- /dev/null +++ b/test/test_message_timestamp_cache/test_message_timestamp_cache.cpp @@ -0,0 +1,75 @@ +#include + +#include + +static void makeFingerprint(uint8_t fingerprint[MAX_HASH_SIZE], uint8_t value) { + memset(fingerprint, value, MAX_HASH_SIZE); +} + +TEST(MessageTimestampCache, ReturnsMappedTimestampForRetry) { + mesh::MessageTimestampCache<4> cache; + uint8_t fingerprint[MAX_HASH_SIZE]; + makeFingerprint(fingerprint, 0x11); + + ASSERT_TRUE(cache.remember(fingerprint, 100U, 500U)); + + uint32_t mapped = 0; + EXPECT_TRUE(cache.find(fingerprint, 100U, &mapped)); + EXPECT_EQ(500U, mapped); +} + +TEST(MessageTimestampCache, DistinguishesLogicalMessages) { + mesh::MessageTimestampCache<4> cache; + uint8_t first[MAX_HASH_SIZE]; + uint8_t second[MAX_HASH_SIZE]; + makeFingerprint(first, 0x21); + makeFingerprint(second, 0x22); + + ASSERT_TRUE(cache.remember(first, 100U, 500U)); + + EXPECT_FALSE(cache.find(first, 101U)); + EXPECT_FALSE(cache.find(second, 100U)); +} + +TEST(MessageTimestampCache, UpdatesAnExistingMapping) { + mesh::MessageTimestampCache<2> cache; + uint8_t fingerprint[MAX_HASH_SIZE]; + makeFingerprint(fingerprint, 0x33); + + ASSERT_TRUE(cache.remember(fingerprint, 7U, 70U)); + ASSERT_TRUE(cache.remember(fingerprint, 7U, 71U)); + + uint32_t mapped = 0; + EXPECT_TRUE(cache.find(fingerprint, 7U, &mapped)); + EXPECT_EQ(71U, mapped); +} + +TEST(MessageTimestampCache, ReplacesOldestEntryWhenFull) { + mesh::MessageTimestampCache<2> cache; + uint8_t first[MAX_HASH_SIZE]; + uint8_t second[MAX_HASH_SIZE]; + uint8_t third[MAX_HASH_SIZE]; + makeFingerprint(first, 0x41); + makeFingerprint(second, 0x42); + makeFingerprint(third, 0x43); + + ASSERT_TRUE(cache.remember(first, 1U, 101U)); + ASSERT_TRUE(cache.remember(second, 2U, 102U)); + ASSERT_TRUE(cache.remember(third, 3U, 103U)); + + EXPECT_FALSE(cache.find(first, 1U)); + EXPECT_TRUE(cache.find(second, 2U)); + EXPECT_TRUE(cache.find(third, 3U)); +} + +TEST(MessageTimestampCache, RejectsNullFingerprint) { + mesh::MessageTimestampCache<2> cache; + + EXPECT_FALSE(cache.remember(NULL, 1U, 2U)); + EXPECT_FALSE(cache.find(NULL, 1U)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}