Files
MeshCore/src/helpers/MQTTPayloadBuilder.cpp
T
agessaman e242cc6fd3 fix(mqtt): address review of the demand-driven slot work
[P1] A failed setup no longer strands the slot. setupSlot() returns bool and the
startup loops count only successful activations, so a slot that fails on a client
allocation neither consumes an active-slot position (starving a later healthy
broker on capped hardware) nor sits dead forever: maintainSlotConnections()
previously skipped clientless slots and the reconnect ladder is gated on
initial_connect_done, so nothing retried it. It now retries an enabled but
unactivated slot on a 60 s timer, one per cycle, gated on the same
_slots_setup_done ordering so the NTP-deferred setup sequence is preserved.

[P2] JWT setup no longer proceeds without a usable token. Both the preset and
custom-audience paths returned after ignoring createSlotAuthToken()'s result,
then called connect() and latched initial_connect_done -- so the token-allocation
failure introduced by the previous commit produced an unauthenticated attempt
exactly when memory was exhausted. They now return false and let the retry path
handle it.

[P2] ensureSlotAuthToken() no longer clears an existing token. It cleared
unconditionally, so every renewal wiped the current token before JWTHelper ran;
a renewal that then failed left an empty password where the inline buffer used to
preserve working credentials (JWTHelper writes only on success). Only freshly
allocated buffers are initialised now.

[P2] Raw publications reuse the shared document. buildRawJSON() reached
MQTTPayloadBuilder::buildRawMessage(), which constructed its own default
JsonDocument and therefore malloc'd and freed an internal-heap variant pool per
message -- on the highest-rate topic. The document is threaded through both
builders and the bridge passes _json_scratch_doc.

[P3] The writeTo() guard validates the source fields, not just the destination.
A corrupt payload_len of MAX_PACKET_PAYLOAD + 1 still leaves getRawLength()
inside MAX_TRANS_UNIT, so writeTo() read past packet->payload. Sizing and
validation moved to a pure MQTTWireScratch header with host tests covering the
accept/reject edges, matching the MQTTPacketFilter/MQTTConnectionPolicy pattern.
Two findings fell out: MAX_PATH_SIZE one-byte hops is not encodable (the hop
count is 6 bits, so 64 & 63 == 0; 32 two-byte hops is the widest real path), and
a zero-payload packet serializes but does not survive readFrom() -- pinned as a
test because it constrains any future wire-only queue.

[P3] Corrected the pool-size comment: these targets are 32-bit, so
ARDUINOJSON_SLOT_ID_SIZE is 2 and a pool block is 128 slots / 1024 bytes, not
4096. The 4096 figure came from a pre-existing comment near
NEIGHBORS_DOC_POOL_BUDGET, which is left alone -- its byte measurements are
empirical and still stand, only the block-size attribution is wrong.

Activation is now centralized in activatedSlotCount()/canActivateSlot(), used by
both startup loops, the retry path, and applySlotPreset(). That closes the
pre-existing divergence where a live preset change called setupSlot() without
consulting _max_active_slots, letting a non-PSRAM board reach three concurrent
TLS sessions against a cap of two. BEHAVIOUR CHANGE: a reconfigure that would
exceed the cap now logs and leaves the slot inactive instead of connecting.
Reconfiguring an already-active slot still works, because teardownSlot() releases
its position first.

272/272 native tests pass (5 new); both observer envs and an nRF52 repeater build
clean. Flash 1593249 B non-PSRAM, 1555625 B PSRAM.
2026-08-04 10:13:04 -07:00

296 lines
8.3 KiB
C++

#include "MQTTPayloadBuilder.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
namespace {
static int serializeComplete(JsonObject root, char* buffer, size_t buffer_size) {
if (!buffer || buffer_size == 0) return 0;
size_t written = serializeJson(root, buffer, buffer_size);
// Preserve MQTTMessageBuilder's existing success criterion while clearing
// ArduinoJson's truncated prefix on failure. Callers publish only a positive
// return value, and now a failed buffer cannot be mistaken for complete JSON.
if (written == 0 || written >= buffer_size) {
buffer[0] = '\0';
return 0;
}
return static_cast<int>(written);
}
} // namespace
int MQTTPayloadBuilder::buildStatusMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* model,
const char* firmware_version,
const char* radio,
const char* client_version,
const char* status,
const char* timestamp,
char* buffer,
size_t buffer_size,
int battery_mv,
int uptime_secs,
int errors,
int queue_len,
int noise_floor,
int tx_air_secs,
int rx_air_secs,
int recv_errors,
int internal_heap,
int packets_sent,
int packets_received,
const char* repeat
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["status"] = status;
root["timestamp"] = timestamp;
root["origin"] = origin;
root["origin_id"] = origin_id;
root["model"] = model;
root["firmware_version"] = firmware_version;
root["radio"] = radio;
root["client_version"] = client_version;
if (repeat != nullptr) {
root["repeat"] = repeat;
}
if (battery_mv >= 0 || uptime_secs >= 0 || errors >= 0 || queue_len >= 0 ||
noise_floor > -999 || tx_air_secs >= 0 || rx_air_secs >= 0 || recv_errors >= 0 ||
internal_heap >= 0 || packets_sent >= 0 || packets_received >= 0) {
JsonObject stats = root["stats"].to<JsonObject>();
if (battery_mv >= 0) stats["battery_mv"] = battery_mv;
if (uptime_secs >= 0) stats["uptime_secs"] = uptime_secs;
if (packets_sent >= 0) stats["packets_sent"] = packets_sent;
if (packets_received >= 0) stats["packets_received"] = packets_received;
if (errors >= 0) stats["errors"] = errors;
if (queue_len >= 0) stats["queue_len"] = queue_len;
if (noise_floor > -999) stats["noise_floor"] = noise_floor;
if (tx_air_secs >= 0) stats["tx_air_secs"] = tx_air_secs;
if (rx_air_secs >= 0) stats["rx_air_secs"] = rx_air_secs;
if (recv_errors >= 0) stats["recv_errors"] = recv_errors;
if (internal_heap >= 0) stats["internal_heap"] = internal_heap;
}
return serializeComplete(root, buffer, buffer_size);
}
int MQTTPayloadBuilder::buildPacketMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* direction,
const char* time,
const char* date,
int len,
int packet_type,
const char* route,
int payload_len,
const char* raw,
float snr,
int rssi,
float score,
const char* hash,
const uint8_t* path_bytes,
int path_hop_count,
int path_hash_size,
size_t max_path_bytes,
char* buffer,
size_t buffer_size
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
char len_str[16];
char packet_type_str[16];
char payload_len_str[16];
char snr_str[16];
char rssi_str[16];
char score_str[16];
snprintf(len_str, sizeof(len_str), "%d", len);
snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type);
snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len);
snprintf(snr_str, sizeof(snr_str), "%.1f", snr);
snprintf(rssi_str, sizeof(rssi_str), "%d", rssi);
root["timestamp"] = timestamp;
root["hash"] = hash;
root["origin"] = origin;
root["type"] = "PACKET";
root["direction"] = direction;
root["time"] = time;
root["date"] = date;
root["len"] = len_str;
root["packet_type"] = packet_type_str;
root["route"] = route;
root["payload_len"] = payload_len_str;
root["raw"] = raw;
root["origin_id"] = origin_id;
if (direction && strcmp(direction, "rx") == 0) {
root["SNR"] = snr_str;
root["RSSI"] = rssi_str;
if (!isnan(score)) {
snprintf(score_str, sizeof(score_str), "%d", static_cast<int>(score * 1000));
root["score"] = score_str;
}
}
if (path_bytes && path_hop_count > 0 && path_hash_size > 0) {
JsonArray path_arr = root["path"].to<JsonArray>();
char hop_hex[2 * 4 + 1];
for (int i = 0; i < path_hop_count; i++) {
size_t pos = 0;
for (int b = 0; b < path_hash_size && b < 4; b++) {
size_t idx = static_cast<size_t>(i) * path_hash_size + b;
if (idx >= max_path_bytes) break;
snprintf(hop_hex + pos, 3, "%02x", path_bytes[idx]);
pos += 2;
}
hop_hex[pos] = '\0';
path_arr.add(hop_hex);
}
}
return serializeComplete(root, buffer, buffer_size);
}
int MQTTPayloadBuilder::buildRawMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* raw,
char* buffer,
size_t buffer_size
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["origin"] = origin;
root["origin_id"] = origin_id;
root["timestamp"] = timestamp;
root["type"] = "RAW";
root["data"] = raw;
return serializeComplete(root, buffer, buffer_size);
}
static JsonArray buildNeighborsMessageBase(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors,
int queried_neighbors,
bool truncated
) {
doc.clear();
JsonObject root = doc.to<JsonObject>();
root["timestamp"] = timestamp;
root["origin"] = origin;
root["origin_id"] = origin_id;
if (total_neighbors >= 0) {
root["total_neighbors"] = total_neighbors;
root["queried_neighbors"] = queried_neighbors >= 0 ? queried_neighbors : total_neighbors;
root["truncated"] = truncated;
}
JsonObject self = root["self"].to<JsonObject>();
self["scopes"] = self_scopes ? self_scopes : "";
self["default_scope"] = self_default_scope ? self_default_scope : "";
return root["neighbors"].to<JsonArray>();
}
static void addNeighborsMessageEntry(
JsonArray& arr,
const MQTTPayloadBuilder::NeighborsMessageEntry& neighbor
) {
JsonObject nb = arr.add<JsonObject>();
nb["pubkey"] = neighbor.pubkey_hex;
nb["snr"] = neighbor.snr;
if (neighbor.heard_unknown) {
nb["heard_secs_ago"] = nullptr; // age unknown, not zero
} else {
nb["heard_secs_ago"] = neighbor.heard_secs_ago;
}
nb["scopes"] = neighbor.scopes ? neighbor.scopes : "";
nb["status"] = neighbor.status;
}
size_t MQTTPayloadBuilder::measureNeighborsMessageBase(
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
int total_neighbors
) {
JsonDocument doc;
buildNeighborsMessageBase(
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
total_neighbors, total_neighbors, false);
return measureJson(doc);
}
size_t MQTTPayloadBuilder::measureNeighborsMessageEntry(
const NeighborsMessageEntry& neighbor
) {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
addNeighborsMessageEntry(arr, neighbor);
JsonObject entry = arr[0];
return measureJson(entry);
}
int MQTTPayloadBuilder::buildNeighborsMessage(
JsonDocument& doc,
const char* origin,
const char* origin_id,
const char* timestamp,
const char* self_scopes,
const char* self_default_scope,
const NeighborsMessageEntry* neighbors,
int neighbor_count,
char* buffer,
size_t buffer_size,
int total_neighbors,
int queried_neighbors,
bool truncated
) {
if (!buffer || buffer_size == 0) return 0;
JsonArray arr = buildNeighborsMessageBase(
doc, origin, origin_id, timestamp, self_scopes, self_default_scope,
total_neighbors, queried_neighbors, truncated);
if (doc.overflowed() || arr.isNull() || measureJson(doc) >= buffer_size) return 0;
for (int i = 0; i < neighbor_count; i++) {
addNeighborsMessageEntry(arr, neighbors[i]);
if (doc.overflowed()) return 0;
// 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(doc) >= buffer_size) {
arr.remove(arr.size() - 1);
if (total_neighbors >= 0) doc["truncated"] = true;
break;
}
}
return serializeComplete(doc.as<JsonObject>(), buffer, buffer_size);
}