diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index fb0d4399..2fe44d6b 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -102,6 +102,22 @@ int MQTTMessageBuilder::buildRawMessage( origin, origin_id, timestamp, raw, buffer, buffer_size); } +int MQTTMessageBuilder::buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size +) { + return MQTTPayloadBuilder::buildNeighborsMessage( + doc, origin, origin_id, timestamp, self_scopes, neighbors, neighbor_count, + buffer, buffer_size); +} + int MQTTMessageBuilder::buildPacketJSON( JsonDocument& doc, mesh::Packet* packet, diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index 8e3a6d45..fc0c4705 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -2,6 +2,7 @@ #include "MeshCore.h" #include +#include "MQTTPayloadBuilder.h" #include #include @@ -153,6 +154,22 @@ public: size_t buffer_size ); + // Neighbors table entry + JSON builder. The layout logic lives in the pure, + // host-tested MQTTPayloadBuilder; this is the firmware-facing alias/delegate, + // matching the status/packet/raw builders. + using NeighborsMessageEntry = MQTTPayloadBuilder::NeighborsMessageEntry; + static int buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size + ); + /** * Convert packet to JSON message * diff --git a/src/helpers/MQTTPayloadBuilder.cpp b/src/helpers/MQTTPayloadBuilder.cpp index 260fac5f..5a40e502 100644 --- a/src/helpers/MQTTPayloadBuilder.cpp +++ b/src/helpers/MQTTPayloadBuilder.cpp @@ -184,3 +184,48 @@ int MQTTPayloadBuilder::buildRawMessage( return serializeComplete(root, buffer, buffer_size); } + +int MQTTPayloadBuilder::buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size +) { + if (!buffer || buffer_size == 0) return 0; + + doc.clear(); + JsonObject root = doc.to(); + root["timestamp"] = timestamp; + root["origin"] = origin; + root["origin_id"] = origin_id; + + JsonObject self = root["self"].to(); + self["scopes"] = self_scopes ? self_scopes : ""; + + JsonArray arr = root["neighbors"].to(); + if (measureJson(root) >= buffer_size) return 0; + + for (int i = 0; i < neighbor_count; i++) { + JsonObject nb = arr.add(); + nb["pubkey"] = neighbors[i].pubkey_hex; + nb["snr"] = neighbors[i].snr; + nb["heard_secs_ago"] = neighbors[i].heard_secs_ago; + nb["scopes"] = neighbors[i].scopes ? neighbors[i].scopes : ""; + nb["status"] = neighbors[i].status; + + // Entries arrive ordered most- to least-useful. Stop as soon as the next + // one would fill the fixed publish buffer, dropping the remaining tail so + // document growth stays bounded. + if (measureJson(root) >= buffer_size) { + arr.remove(arr.size() - 1); + break; + } + } + + return serializeComplete(root, buffer, buffer_size); +} diff --git a/src/helpers/MQTTPayloadBuilder.h b/src/helpers/MQTTPayloadBuilder.h index 57793b13..a6fa333d 100644 --- a/src/helpers/MQTTPayloadBuilder.h +++ b/src/helpers/MQTTPayloadBuilder.h @@ -68,5 +68,28 @@ public: char* buffer, size_t buffer_size ); + + struct NeighborsMessageEntry { + const char* pubkey_hex; + float snr; + uint32_t heard_secs_ago; + const char* scopes; + const char* status; + }; + + // Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic. + // Callers order entries most- to least-useful; document growth is bounded to + // buffer_size and the remaining tail is dropped once the next entry won't fit. + static int buildNeighborsMessage( + JsonDocument& doc, + const char* origin, + const char* origin_id, + const char* timestamp, + const char* self_scopes, + const NeighborsMessageEntry* neighbors, + int neighbor_count, + char* buffer, + size_t buffer_size + ); }; diff --git a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp index 7dfcf965..d7f81130 100644 --- a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp +++ b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp @@ -227,6 +227,87 @@ TEST(MQTTPayloadBuilder, MaximumRepresentativePacketAndRawPayloadsRemainValid) { EXPECT_EQ(510U, strlen(parsed_raw["data"].as())); } +TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = { + {"0011223344556677", 9.75f, 42, "DEN,APRS", "active"}, + {"8899AABBCCDDEEFF", -3.5f, 3600, "", "stale"}, + }; + + JsonDocument scratch; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", + neighbors, 2, buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_EQ(static_cast(len), strlen(buffer)); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("2026-07-18T12:34:56.123456+00:00", parsed["timestamp"].as()); + EXPECT_STREQ("DEN Repeater", parsed["origin"].as()); + EXPECT_STREQ("0123456789ABCDEF", parsed["origin_id"].as()); + EXPECT_STREQ("DEN,APRS", parsed["self"]["scopes"].as()); + + JsonArray arr = parsed["neighbors"].as(); + ASSERT_EQ(2U, arr.size()); + EXPECT_STREQ("0011223344556677", arr[0]["pubkey"].as()); + EXPECT_FLOAT_EQ(9.75f, arr[0]["snr"].as()); + EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as()); + EXPECT_STREQ("DEN,APRS", arr[0]["scopes"].as()); + EXPECT_STREQ("active", arr[0]["status"].as()); + EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as()); + EXPECT_STREQ("", arr[1]["scopes"].as()); + EXPECT_STREQ("stale", arr[1]["status"].as()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageHandlesEmptyTableAndNullScopes) { + JsonDocument scratch; + char buffer[256]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, nullptr, nullptr, 0, + buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("", parsed["self"]["scopes"].as()); + JsonArray arr = parsed["neighbors"].as(); + ASSERT_TRUE(arr.isNull() == false); + EXPECT_EQ(0U, arr.size()); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) { + // Twenty entries far exceed a tight buffer; the builder must emit a prefix + // that still parses as complete JSON rather than truncating mid-document. + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20]; + static char keys[20][17]; + for (int i = 0; i < 20; i++) { + snprintf(keys[i], sizeof(keys[i]), "%016X", i); + neighbors[i].pubkey_hex = keys[i]; + neighbors[i].snr = static_cast(i); + neighbors[i].heard_secs_ago = static_cast(i) * 10U; + neighbors[i].scopes = "DEN"; + neighbors[i].status = "active"; + } + + JsonDocument scratch; + char buffer[512]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "node", "id", kTimestamp, "DEN", neighbors, 20, + buffer, sizeof(buffer)); + + ASSERT_GT(len, 0); + EXPECT_LT(static_cast(len), sizeof(buffer)); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + JsonArray arr = parsed["neighbors"].as(); + ASSERT_FALSE(arr.isNull()); + EXPECT_GT(arr.size(), 0U); + EXPECT_LT(arr.size(), 20U); + // Kept entries are the head of the input, in order. + EXPECT_STREQ(keys[0], arr[0]["pubkey"].as()); +} + } // namespace int main(int argc, char** argv) {