From 7e3daab953c46d4cbc53ba2b1253f08aca38699e Mon Sep 17 00:00:00 2001 From: Ben Batt Date: Mon, 20 Jul 2026 21:05:28 +1000 Subject: [PATCH 1/5] Add support for room server topics --- examples/simple_room_server/MyMesh.cpp | 102 ++++++++++++++++++++++--- examples/simple_room_server/MyMesh.h | 6 ++ 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 0aff39cc1..8e7d39bdf 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -107,6 +107,49 @@ void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { } } +void MyMesh::pushTopicToClient(ClientInfo *client) { + int len = 0; + memcpy(&reply_data[len], &topic_timestamp, 4); + len += 4; // this is a PAST timestamp... but should be accepted by client + + uint8_t attempt; + getRNG()->random(&attempt, 1); // need this for re-tries, so packet hash (and ACK) will be different + reply_data[len++] = (TXT_TYPE_SIGNED_PLAIN << 2) | (attempt & 3); // 'signed' plain text + + // encode prefix of self_id.pub_key + memcpy(&reply_data[len], self_id.pub_key, 4); + len += 4; // just first 4 bytes + + memcpy(&reply_data[len], "Topic: ", 7); + len += 7; + + int text_len = strlen(topic); + memcpy(&reply_data[len], topic, text_len); + len += text_len; + + // calc expected ACK reply + mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); + client->extra.room.push_post_timestamp = topic_timestamp; + + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); + if (reply) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + unsigned long delay_millis = 0; + sendFloodScoped(default_scope, reply, delay_millis, _prefs.path_hash_mode + 1); // REVISIT + client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD); + } else { + sendDirect(reply, client->out_path, client->out_path_len); + + uint8_t path_hash_count = client->out_path_len & 63; + client->extra.room.ack_timeout = futureMillis(PUSH_TIMEOUT_BASE + PUSH_ACK_TIMEOUT_FACTOR * (path_hash_count + 1)); + } + _num_post_pushes++; // stats + } else { + client->extra.room.pending_ack = 0; + MESH_DEBUG_PRINTLN("Unable to push topic to client"); + } +} + uint8_t MyMesh::getUnsyncedCount(ClientInfo *client) { uint8_t count = 0; for (int k = 0; k < MAX_UNSYNCED_POSTS; k++) { @@ -688,6 +731,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc next_client_idx = 0; next_push = 0; memset(posts, 0, sizeof(posts)); + memset(topic, 0, sizeof(topic)); + topic_timestamp = 0; _num_posted = _num_post_pushes = 0; memset(default_scope.key, 0, sizeof(default_scope.key)); @@ -925,10 +970,35 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply while (*command == ' ') command++; // skip leading spaces - if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) + int command_length = strlen(command); + + if (command_length > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) memcpy(reply, command, 3); // reflect the prefix back reply += 3; command += 3; + command_length -= 3; + } + + // handle topic related commands + if (command_length >= 10 && memcmp(command, "set topic ", 10) == 0) { + char* new_topic = &command[10]; + int len = strlen(new_topic); + + if (len > MAX_TOPIC_TEXT_LEN) { + sprintf(reply, "Err - length (%d) > maximum (%d)", len, MAX_TOPIC_TEXT_LEN); + return; + } + + strcpy(topic, new_topic); + topic_timestamp = getRTCClock()->getCurrentTimeUnique(); + + sprintf(reply, "New topic set with length %d", len); + + return; + } else if (command_length >= 9 && memcmp(command, "get topic", 9) == 0) { + sprintf(reply, "Topic: %s", topic); + + return; } // handle ACL related commands @@ -1003,18 +1073,26 @@ void MyMesh::loop() { client->extra.room.push_failures < 3) { // not already waiting for ACK, AND not evicted, AND retries not max MESH_DEBUG_PRINTLN("loop - checking for client %02X", (uint32_t)client->id.pub_key[0]); uint32_t now = getRTCClock()->getCurrentTime(); - for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; k++) { - auto p = &posts[idx]; - if (now >= p->post_timestamp + POST_SYNC_DELAY_SECS && - p->post_timestamp > client->extra.room.sync_since // is new post for this Client? - && !p->author.matches(client->id)) { // don't push posts to the author - // push this post to Client, then wait for ACK - pushPostToClient(client, *p); - did_push = true; - MESH_DEBUG_PRINTLN("loop - pushed to client %02X: %s", (uint32_t)client->id.pub_key[0], p->text); - break; + + if (now >= topic_timestamp + POST_SYNC_DELAY_SECS && topic_timestamp > client->extra.room.sync_since) { + // client hasn't seen this topic; push it, then wait for ACK + pushTopicToClient(client); + did_push = true; + MESH_DEBUG_PRINTLN("loop - pushed to client %02X: %s", (uint32_t)client->id.pub_key[0], p->text); + } else { + for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; k++) { + auto p = &posts[idx]; + if (now >= p->post_timestamp + POST_SYNC_DELAY_SECS && + p->post_timestamp > client->extra.room.sync_since // is new post for this Client? + && !p->author.matches(client->id)) { // don't push posts to the author + // push this post to Client, then wait for ACK + pushPostToClient(client, *p); + did_push = true; + MESH_DEBUG_PRINTLN("loop - pushed to client %02X: %s", (uint32_t)client->id.pub_key[0], p->text); + break; + } + idx = (idx + 1) % MAX_UNSYNCED_POSTS; // wrap to start of cyclic queue } - idx = (idx + 1) % MAX_UNSYNCED_POSTS; // wrap to start of cyclic queue } } else { MESH_DEBUG_PRINTLN("loop - skipping busy (or evicted) client %02X", (uint32_t)client->id.pub_key[0]); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5f78bee81..c396ede02 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -82,6 +82,9 @@ #define MAX_POST_TEXT_LEN (160-9) +// subtract 7 for "Topic: " prefix +#define MAX_TOPIC_TEXT_LEN (MAX_POST_TEXT_LEN - 7) + struct PostInfo { mesh::Identity author; uint32_t post_timestamp; // by OUR clock @@ -107,6 +110,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int next_client_idx; // for round-robin polling int next_post_idx; PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue + char topic[MAX_TOPIC_TEXT_LEN + 1]; + uint32_t topic_timestamp; // by OUR clock CayenneLPP telemetry; RegionEntry* load_stack[8]; RegionEntry* recv_pkt_region; @@ -121,6 +126,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void addPost(ClientInfo* client, const char* postData); void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); + void pushTopicToClient(ClientInfo* client); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); mesh::Packet* createSelfAdvert(); From cb7bdb310fe009ddbd54ac8ad14961f2208774d5 Mon Sep 17 00:00:00 2001 From: Ben Batt Date: Mon, 20 Jul 2026 21:30:52 +1000 Subject: [PATCH 2/5] Refactor common logic from pushPostToClient() and pushTopicToClient() into pushPostInternal() --- examples/simple_room_server/MyMesh.cpp | 64 ++++++++------------------ examples/simple_room_server/MyMesh.h | 1 + 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 8e7d39bdf..51e107b5e 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -68,68 +68,40 @@ void MyMesh::storePost(const mesh::Identity &author, const char *postData) { void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { MESH_DEBUG_PRINTLN("room.post: pushPostToClient text=%s", post.text); - int len = 0; - memcpy(&reply_data[len], &post.post_timestamp, 4); - len += 4; // this is a PAST timestamp... but should be accepted by client - - uint8_t attempt; - getRNG()->random(&attempt, 1); // need this for re-tries, so packet hash (and ACK) will be different - reply_data[len++] = (TXT_TYPE_SIGNED_PLAIN << 2) | (attempt & 3); // 'signed' plain text - - // encode prefix of post.author.pub_key - memcpy(&reply_data[len], post.author.pub_key, 4); - len += 4; // just first 4 bytes - - int text_len = strlen(post.text); - memcpy(&reply_data[len], post.text, text_len); - len += text_len; - - // calc expected ACK reply - mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); - client->extra.room.push_post_timestamp = post.post_timestamp; - - auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); - if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - unsigned long delay_millis = 0; - sendFloodScoped(default_scope, reply, delay_millis, _prefs.path_hash_mode + 1); // REVISIT - client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD); - } else { - sendDirect(reply, client->out_path, client->out_path_len); - - uint8_t path_hash_count = client->out_path_len & 63; - client->extra.room.ack_timeout = futureMillis(PUSH_TIMEOUT_BASE + PUSH_ACK_TIMEOUT_FACTOR * (path_hash_count + 1)); - } - _num_post_pushes++; // stats - } else { - client->extra.room.pending_ack = 0; - MESH_DEBUG_PRINTLN("Unable to push post to client"); - } + pushPostInternal(client, post.post_timestamp, post.author, nullptr, post.text); } void MyMesh::pushTopicToClient(ClientInfo *client) { + pushPostInternal(client, topic_timestamp, self_id, "Topic: ", topic); +} + +void MyMesh::pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, + const char* prefix, const char* text) { int len = 0; - memcpy(&reply_data[len], &topic_timestamp, 4); + memcpy(&reply_data[len], ×tamp, 4); len += 4; // this is a PAST timestamp... but should be accepted by client uint8_t attempt; getRNG()->random(&attempt, 1); // need this for re-tries, so packet hash (and ACK) will be different reply_data[len++] = (TXT_TYPE_SIGNED_PLAIN << 2) | (attempt & 3); // 'signed' plain text - // encode prefix of self_id.pub_key - memcpy(&reply_data[len], self_id.pub_key, 4); + // encode prefix of author.pub_key + memcpy(&reply_data[len], author.pub_key, 4); len += 4; // just first 4 bytes - memcpy(&reply_data[len], "Topic: ", 7); - len += 7; + if (prefix) { + int prefix_len = strlen(prefix); + memcpy(&reply_data[len], prefix, prefix_len); + len += prefix_len; + } - int text_len = strlen(topic); - memcpy(&reply_data[len], topic, text_len); + int text_len = strlen(text); + memcpy(&reply_data[len], text, text_len); len += text_len; // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); - client->extra.room.push_post_timestamp = topic_timestamp; + client->extra.room.push_post_timestamp = timestamp; auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); if (reply) { @@ -146,7 +118,7 @@ void MyMesh::pushTopicToClient(ClientInfo *client) { _num_post_pushes++; // stats } else { client->extra.room.pending_ack = 0; - MESH_DEBUG_PRINTLN("Unable to push topic to client"); + MESH_DEBUG_PRINTLN("Unable to push post to client"); } } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index c396ede02..73fe5ab23 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -127,6 +127,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); void pushTopicToClient(ClientInfo* client); + void pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, const char* prefix, const char* text); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); mesh::Packet* createSelfAdvert(); From 7adff025fedb7d74c08c57fb77d55ca5f298e929 Mon Sep 17 00:00:00 2001 From: Ben Batt Date: Wed, 22 Jul 2026 21:12:14 +1000 Subject: [PATCH 3/5] Save room topic to a new "/room_prefs" file --- examples/simple_room_server/MyMesh.cpp | 46 ++++++++++++++++++++++++++ examples/simple_room_server/MyMesh.h | 8 ++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 51e107b5e..faa59eaf4 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -714,6 +714,7 @@ void MyMesh::begin(FILESYSTEM *fs) { mesh::Mesh::begin(); _fs = fs; // load persisted prefs + loadRoomPrefs(); _cli.loadPrefs(_fs); acl.load(_fs, self_id); @@ -747,6 +748,10 @@ void MyMesh::begin(FILESYSTEM *fs) { updateAdvertTimer(); updateFloodAdvertTimer(); + if (strlen(topic) > 0) { + topic_timestamp = getRTCClock()->getCurrentTimeUnique(); + } + board.setAdcMultiplier(_prefs.adc_multiplier); #if ENV_INCLUDE_GPS == 1 @@ -754,6 +759,45 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } +#define PREFS_FILE_PATH "/room_prefs" + +void MyMesh::loadRoomPrefs() { +#if defined(RP2040_PLATFORM) + File file = _fs->open(PREFS_FILE_PATH, "r"); +#else + File file = _fs->open(PREFS_FILE_PATH); +#endif + if (file) { + file.read((uint8_t *)&topic[0], sizeof(topic)); // 0 + topic[MAX_TOPIC_TEXT_LEN] = '\0'; + // next: 144 + + file.close(); + } +} + +void MyMesh::saveRoomPrefs() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove(PREFS_FILE_PATH); + File file = _fs->open(PREFS_FILE_PATH, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File file = _fs->open(PREFS_FILE_PATH, "w"); +#else + File file = _fs->open(PREFS_FILE_PATH, "w", true); +#endif + if (file) { + file.write((uint8_t *)&topic[0], sizeof(topic)); // 0 + // next: 144 + + file.close(); + } +} + +void MyMesh::savePrefs() { + saveRoomPrefs(); + _cli.savePrefs(_fs); +} + void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { if (scope.isNull()) { sendFlood(pkt, delay_millis, path_hash_size); @@ -964,6 +1008,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply strcpy(topic, new_topic); topic_timestamp = getRTCClock()->getCurrentTimeUnique(); + saveRoomPrefs(); + sprintf(reply, "New topic set with length %d", len); return; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 73fe5ab23..398ffea7d 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -134,6 +134,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { File openAppend(const char* fname); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); + void loadRoomPrefs(); + void saveRoomPrefs(); + protected: float getAirtimeBudgetFactor() const override { return _prefs.airtime_factor; @@ -194,13 +197,10 @@ public: return &_prefs; } - void savePrefs() override { - _cli.savePrefs(_fs); - } - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); // CommonCLICallbacks + void savePrefs() override; void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; From d8dc445fb7a869c7ee87547b0ab6baab431b7f25 Mon Sep 17 00:00:00 2001 From: Ben Batt Date: Thu, 23 Jul 2026 20:48:21 +1000 Subject: [PATCH 4/5] Remove "Topic: " prefix from room topic messages --- examples/simple_room_server/MyMesh.cpp | 17 +++++------------ examples/simple_room_server/MyMesh.h | 5 ++--- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index faa59eaf4..3557ada72 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -68,15 +68,14 @@ void MyMesh::storePost(const mesh::Identity &author, const char *postData) { void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { MESH_DEBUG_PRINTLN("room.post: pushPostToClient text=%s", post.text); - pushPostInternal(client, post.post_timestamp, post.author, nullptr, post.text); + pushPostInternal(client, post.post_timestamp, post.author, post.text); } void MyMesh::pushTopicToClient(ClientInfo *client) { - pushPostInternal(client, topic_timestamp, self_id, "Topic: ", topic); + pushPostInternal(client, topic_timestamp, self_id, topic); } -void MyMesh::pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, - const char* prefix, const char* text) { +void MyMesh::pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, const char* text) { int len = 0; memcpy(&reply_data[len], ×tamp, 4); len += 4; // this is a PAST timestamp... but should be accepted by client @@ -89,12 +88,6 @@ void MyMesh::pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh memcpy(&reply_data[len], author.pub_key, 4); len += 4; // just first 4 bytes - if (prefix) { - int prefix_len = strlen(prefix); - memcpy(&reply_data[len], prefix, prefix_len); - len += prefix_len; - } - int text_len = strlen(text); memcpy(&reply_data[len], text, text_len); len += text_len; @@ -770,7 +763,7 @@ void MyMesh::loadRoomPrefs() { if (file) { file.read((uint8_t *)&topic[0], sizeof(topic)); // 0 topic[MAX_TOPIC_TEXT_LEN] = '\0'; - // next: 144 + // next: 151 file.close(); } @@ -787,7 +780,7 @@ void MyMesh::saveRoomPrefs() { #endif if (file) { file.write((uint8_t *)&topic[0], sizeof(topic)); // 0 - // next: 144 + // next: 151 file.close(); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 398ffea7d..aee1b9961 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -82,8 +82,7 @@ #define MAX_POST_TEXT_LEN (160-9) -// subtract 7 for "Topic: " prefix -#define MAX_TOPIC_TEXT_LEN (MAX_POST_TEXT_LEN - 7) +#define MAX_TOPIC_TEXT_LEN MAX_POST_TEXT_LEN struct PostInfo { mesh::Identity author; @@ -127,7 +126,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); void pushTopicToClient(ClientInfo* client); - void pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, const char* prefix, const char* text); + void pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, const char* text); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); mesh::Packet* createSelfAdvert(); From c6257796823bbf701cd0ee924437708b429064c0 Mon Sep 17 00:00:00 2001 From: Ben Batt Date: Thu, 30 Jul 2026 21:53:10 +1000 Subject: [PATCH 5/5] Wrap long lines --- examples/simple_room_server/MyMesh.cpp | 3 ++- examples/simple_room_server/MyMesh.h | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 3557ada72..934ff3d5a 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -75,7 +75,8 @@ void MyMesh::pushTopicToClient(ClientInfo *client) { pushPostInternal(client, topic_timestamp, self_id, topic); } -void MyMesh::pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, const char* text) { +void MyMesh::pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, + const char* text) { int len = 0; memcpy(&reply_data[len], ×tamp, 4); len += 4; // this is a PAST timestamp... but should be accepted by client diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index aee1b9961..f370324fa 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -126,7 +126,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); void pushTopicToClient(ClientInfo* client); - void pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, const char* text); + void pushPostInternal(ClientInfo* client, uint32_t timestamp, const mesh::Identity& author, + const char* text); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); mesh::Packet* createSelfAdvert();