From fad11c90f338883f2cd7b728f3e1db61cdbd815e Mon Sep 17 00:00:00 2001 From: ViezeVingertjes Date: Mon, 3 Aug 2026 22:55:13 +0200 Subject: [PATCH 01/40] Fix replies dropped when flood.max.unscoped is low --- examples/simple_repeater/MyMesh.cpp | 57 ++++++--- examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 31 +++-- examples/simple_room_server/MyMesh.h | 1 + src/helpers/RoutingPolicy.h | 68 +++++++++++ .../test_routing_policy.cpp | 110 ++++++++++++++++++ 6 files changed, 237 insertions(+), 31 deletions(-) create mode 100644 src/helpers/RoutingPolicy.h create mode 100644 test/test_routing_policy/test_routing_policy.cpp diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 09f74cbe..0d2f5c86 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -414,24 +414,31 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) } void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - if (recv_pkt_region && !recv_pkt_region->isWildcard()) { // if _request_ packet scope is known, send reply with same scope - TransportKey scope; - if (region_map.getTransportKeysFor(*recv_pkt_region, &scope, 1) > 0) { - sendFloodScoped(scope, packet, delay_millis, path_hash_size); - } else { + TransportKey req_scope; + bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); + bool req_scope_known = recv_pkt_region != NULL && !is_wildcard + && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; + + switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request + break; + case mesh::REPLY_SCOPE_DEFAULT: + // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. + // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - } - } else { - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped + break; } } bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood()) { - if (packet->getPathHashCount() >= _prefs.flood_max) return false; - if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; - if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; } if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); @@ -589,18 +596,30 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m if (reply_len == 0) return; // invalid request - if (packet->isRouteFlood()) { + // a DIRECT login can reply via the stored out_path, as onPeerDataRecv() does for REQ + ClientInfo* client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + bool have_out_path = client != NULL && client->out_path_len != OUT_PATH_UNKNOWN; + + auto route = mesh::chooseReplyRoute(packet->isRouteFlood(), reply_path_len >= 0, have_out_path); + + if (route == mesh::REPLY_ROUTE_PATH_RETURN) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet* path = createPathReturn(sender, secret, packet->path, packet->path_len, PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else if (reply_path_len < 0) { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + return; + } + + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + if (reply == NULL) return; + + if (route == mesh::REPLY_ROUTE_DIRECT_SUPPLIED) { uint8_t path_len = ((reply_path_hash_size - 1) << 6) | (reply_path_len & 63); - if (reply) sendDirect(reply, reply_path, path_len, SERVER_RESPONSE_DELAY); + sendDirect(reply, reply_path, path_len, SERVER_RESPONSE_DELAY); + } else if (route == mesh::REPLY_ROUTE_DIRECT_OUT_PATH) { + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + } else { + sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index aa7d30b0..f1a1bcff 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -34,6 +34,7 @@ #include #include #include +#include #include "RateLimiter.h" #ifdef WITH_BRIDGE diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 0aff39cc..b951f427 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -299,10 +299,9 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood()) { - if (packet->getPathHashCount() >= _prefs.flood_max) return false; - if (packet->getRouteType() == ROUTE_TYPE_FLOOD && packet->getPathHashCount() >= _prefs.flood_max_unscoped) return false; - if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= _prefs.flood_max_advert) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; } return true; } @@ -749,15 +748,23 @@ void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint3 } void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - if (recv_pkt_region && !recv_pkt_region->isWildcard()) { // if _request_ packet scope is known, send reply with same scope - TransportKey scope; - if (region_map.getTransportKeysFor(*recv_pkt_region, &scope, 1) > 0) { - sendFloodScoped(scope, packet, delay_millis, path_hash_size); - } else { + TransportKey req_scope; + bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); + bool req_scope_known = recv_pkt_region != NULL && !is_wildcard + && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; + + switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request + break; + case mesh::REPLY_SCOPE_DEFAULT: + // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. + // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - } - } else { - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped + break; } } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5f78bee8..a45ed9cd 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include diff --git a/src/helpers/RoutingPolicy.h b/src/helpers/RoutingPolicy.h new file mode 100644 index 00000000..5251a590 --- /dev/null +++ b/src/helpers/RoutingPolicy.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +namespace mesh { + +/** + * \brief Test a flood packet against the configured hop limits. + * \param packet inbound flood packet (caller has already checked isRouteFlood()) + * \param flood_max max hops for any flood packet + * \param flood_max_unscoped max hops for ROUTE_TYPE_FLOOD (ie. un-scoped) packets + * \param flood_max_advert max hops for ADVERT packets + * \returns true if the packet has exceeded a limit, and must not be forwarded + */ +inline bool isFloodHopLimitExceeded(const Packet* packet, uint8_t flood_max, + uint8_t flood_max_unscoped, uint8_t flood_max_advert) { + uint8_t hops = packet->getPathHashCount(); + if (hops >= flood_max) return true; + if (packet->getRouteType() == ROUTE_TYPE_FLOOD && hops >= flood_max_unscoped) return true; + if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && hops >= flood_max_advert) return true; + return false; +} + +/** + * \brief How a server routes a reply back to the requesting client. + */ +enum ReplyRoute : uint8_t { + REPLY_ROUTE_PATH_RETURN, // request arrived by flood: reply with a PATH return, flooded back + REPLY_ROUTE_DIRECT_SUPPLIED, // reply DIRECT, along the return path supplied in the request + REPLY_ROUTE_DIRECT_OUT_PATH, // reply DIRECT, along the out_path already stored for this client + REPLY_ROUTE_FLOOD, // no return path known: flood the reply +}; + +/** + * \param inbound_is_flood the request arrived as a flood packet + * \param have_supplied_path the request payload carried an explicit reply path + * \param have_out_path this server already has a stored out_path for the client + */ +inline ReplyRoute chooseReplyRoute(bool inbound_is_flood, bool have_supplied_path, bool have_out_path) { + if (inbound_is_flood) return REPLY_ROUTE_PATH_RETURN; + if (have_supplied_path) return REPLY_ROUTE_DIRECT_SUPPLIED; + if (have_out_path) return REPLY_ROUTE_DIRECT_OUT_PATH; + return REPLY_ROUTE_FLOOD; +} + +/** + * \brief Which transport scope a flooded reply should be sent with. + */ +enum ReplyScope : uint8_t { + REPLY_SCOPE_REQUEST, // re-use the scope the request arrived on + REPLY_SCOPE_DEFAULT, // fall back to this node's default region scope + REPLY_SCOPE_NONE, // send un-scoped (ROUTE_TYPE_FLOOD) +}; + +/** + * \param request_scope_known request arrived scoped, and we resolved its Region's key + * \param request_was_unscoped_flood request arrived as an un-scoped flood + * \param default_scope_known this node has a default Region with a usable transport key + */ +inline ReplyScope chooseReplyScope(bool request_scope_known, bool request_was_unscoped_flood, + bool default_scope_known) { + if (request_scope_known) return REPLY_SCOPE_REQUEST; + if (request_was_unscoped_flood) return REPLY_SCOPE_NONE; // requester chose un-scoped, so mirror it + if (default_scope_known) return REPLY_SCOPE_DEFAULT; // scope unknowable: DIRECT, or unresolved Region + return REPLY_SCOPE_NONE; +} + +} diff --git a/test/test_routing_policy/test_routing_policy.cpp b/test/test_routing_policy/test_routing_policy.cpp new file mode 100644 index 00000000..bba9ec79 --- /dev/null +++ b/test/test_routing_policy/test_routing_policy.cpp @@ -0,0 +1,110 @@ +#include +#include "helpers/RoutingPolicy.h" + +using namespace mesh; + +static Packet makeFlood(uint8_t route_type, uint8_t payload_type, uint8_t hops) { + Packet p; + p.header = route_type | (payload_type << PH_TYPE_SHIFT); + p.setPathHashSizeAndCount(1, hops); + p.payload_len = 1; + return p; +} + +TEST(FloodHopLimit, UnscopedFloodIsDroppedAtFirstHopWhenMaxUnscopedIsZero) { + auto pkt = makeFlood(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_RESPONSE, 0); + EXPECT_TRUE(isFloodHopLimitExceeded(&pkt, 64, 0, 8)); +} + +TEST(FloodHopLimit, ScopedFloodIsForwardedWhenMaxUnscopedIsZero) { + for (uint8_t hops = 0; hops < 4; hops++) { + auto pkt = makeFlood(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_RESPONSE, hops); + EXPECT_FALSE(isFloodHopLimitExceeded(&pkt, 64, 0, 8)) << "hops=" << (int)hops; + } +} + +TEST(FloodHopLimit, UnscopedFloodSurvivesUpToMaxUnscopedHops) { + // matches the reported workaround: raising flood.max.unscoped to the expected hop count + auto ok = makeFlood(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_RESPONSE, 2); + EXPECT_FALSE(isFloodHopLimitExceeded(&ok, 64, 3, 8)); + + auto too_far = makeFlood(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_RESPONSE, 3); + EXPECT_TRUE(isFloodHopLimitExceeded(&too_far, 64, 3, 8)); +} + +TEST(FloodHopLimit, ScopedFloodStillHonoursFloodMaxAndAdvertMax) { + auto beyond_max = makeFlood(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_RESPONSE, 5); + EXPECT_TRUE(isFloodHopLimitExceeded(&beyond_max, 5, 64, 8)); + + auto advert = makeFlood(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_ADVERT, 8); + EXPECT_TRUE(isFloodHopLimitExceeded(&advert, 64, 64, 8)); +} + +// flood.max.unscoped=0 hits adverts too, well before flood_max_advert applies: a node +// still advertising un-scoped is invisible past its immediate neighbours +TEST(FloodHopLimit, UnscopedAdvertIsAlsoDroppedAtHopZero) { + auto advert = makeFlood(ROUTE_TYPE_FLOOD, PAYLOAD_TYPE_ADVERT, 0); + EXPECT_TRUE(isFloodHopLimitExceeded(&advert, 64, 0, 8)); + + auto scoped = makeFlood(ROUTE_TYPE_TRANSPORT_FLOOD, PAYLOAD_TYPE_ADVERT, 0); + EXPECT_FALSE(isFloodHopLimitExceeded(&scoped, 64, 0, 8)); +} + +TEST(ReplyRoute, FloodRequestGetsAPathReturn) { + EXPECT_EQ(REPLY_ROUTE_PATH_RETURN, + chooseReplyRoute(true, false, false)); + EXPECT_EQ(REPLY_ROUTE_PATH_RETURN, + chooseReplyRoute(true, false, true)); +} + +TEST(ReplyRoute, DirectRequestWithSuppliedPathRepliesDirect) { + EXPECT_EQ(REPLY_ROUTE_DIRECT_SUPPLIED, chooseReplyRoute(false, true, false)); +} + +// the reported bug: a DIRECT login (app already has a path) was answered by flooding, even +// with an out_path stored. Under flood.max.unscoped=0 that reply never arrives. +TEST(ReplyRoute, DirectRequestWithKnownOutPathRepliesDirect) { + EXPECT_EQ(REPLY_ROUTE_DIRECT_OUT_PATH, + chooseReplyRoute(false, false, true)); +} + +TEST(ReplyRoute, SuppliedPathWinsOverStoredOutPath) { + EXPECT_EQ(REPLY_ROUTE_DIRECT_SUPPLIED, chooseReplyRoute(false, true, true)); +} + +TEST(ReplyRoute, DirectRequestWithNoReturnPathFallsBackToFlood) { + EXPECT_EQ(REPLY_ROUTE_FLOOD, chooseReplyRoute(false, false, false)); +} + +TEST(ReplyScope, MirrorsTheRequestScopeWhenKnown) { + EXPECT_EQ(REPLY_SCOPE_REQUEST, chooseReplyScope(true, + false, + false)); + EXPECT_EQ(REPLY_SCOPE_REQUEST, chooseReplyScope(true, false, true)); +} + +// un-scoped is itself a known scope, so mirror it. Replying scoped would change a path that +// works today, and repeaters not holding our default Region would drop it anyway. +TEST(ReplyScope, RepliesUnscopedToAnUnscopedFloodEvenWhenADefaultScopeExists) { + EXPECT_EQ(REPLY_SCOPE_NONE, chooseReplyScope(false, + true, + true)); +} + +// second half of the bug: a DIRECT request carries no transport codes, so recv_pkt_region is +// always NULL. Un-scoped is dropped under flood.max.unscoped=0, and floods the mesh otherwise. +TEST(ReplyScope, FallsBackToDefaultScopeWhenRequestScopeUnknown) { + EXPECT_EQ(REPLY_SCOPE_DEFAULT, chooseReplyScope(false, + false, + true)); +} + +TEST(ReplyScope, SendsUnscopedOnlyWhenNoScopeIsAvailableAtAll) { + EXPECT_EQ(REPLY_SCOPE_NONE, chooseReplyScope(false, false, false)); + EXPECT_EQ(REPLY_SCOPE_NONE, chooseReplyScope(false, true, false)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From c28548aecf7cdb63d0d9ef5981604e729704a9e0 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 3 Aug 2026 22:15:12 -0700 Subject: [PATCH 02/40] perf(mqtt): tranche 1 memory reductions in the bridge Five independent, behavior-preserving reductions. Measured on Heltec_v3_repeater_observer_mqtt (non-PSRAM) and ThinkNode_M7_repeater_observer_mqtt (PSRAM), 267/267 native tests green. Drop the unused static-task bookkeeping. StaticTask_t _mqtt_task_tcb (344 B) and StackType_t* _mqtt_task_stack were never used: there is no xTaskCreateStatic call, the pointer was assigned nullptr immediately before xTaskCreatePinnedToCore, and the two psram_free() calls on it were dead. Size the wire-format scratch buffers from the protocol maximum. raw_hex[1024] at three sites becomes 2*MAX_TRANS_UNIT+1, and raw_buf/reconstructed[512] become MAX_TRANS_UNIT. Both writeTo() sites now check getRawLength() first: writeTo() does not bounds-check and returns uint8_t, so the old 512-byte buffers were the only thing absorbing a malformed payload_len, and the post-hoc "raw_len > sizeof(buf)" test ran after the overrun. Pass the already-known serialized length into publishToSlot() instead of re-running strlen() per destination slot (up to 2 KB per packet per slot, and NEIGHBORS_JSON_BUFFER_SIZE per neighbor snapshot). Same for the direct publish in publishStatusToSlot(). Share one JSON buffer and one document across packet, raw, and status. All publish paths serialize on the bridge task, so the separate status buffer and document were never concurrent. Status keeps STATUS_JSON_BUFFER_SIZE as its serialization ceiling, so which oversized status documents get dropped is unchanged. Route the document's pools through a PSRAM-preferring allocator. Under ArduinoJson 7 StaticJsonDocument is a deprecated empty subclass of JsonDocument whose template argument only feeds capacity(); the object is 64 B and each pool block (4096 B here) came from plain malloc(), i.e. the internal DRAM the mbedTLS working set needs. Mirrors NeighborsDocAllocator. The old comment claiming an inline pool has been corrected. Measured: sizeof(MQTTBridge) non-PSRAM 13208 -> 12032 B (-1176, internal heap) PSRAM 10492 -> 10080 B (-412, plus one fewer 768 B PSRAM allocation) stack, non-PSRAM buildPacketJSON[FromRaw] 1264 -> 736 B buildRawJSON 1136 -> 608 B publishPacket 688 -> ~432 B packetToHex 560 -> ~304 B deepest publish chain ~2.6 -> ~1.8 KB of 8 KB flash 1592513 -> 1592573 B (+60) static RAM 74656 B unchanged -- MQTTBridge is heap-allocated, so these savings are internal heap, not the linker figure Deferred from the review: the QueuedPacket wire-only redesign (reward is 1.56 KB on non-PSRAM only, and Packet::readFrom() rejects payload_len == 0), demand-driven slot clients/JWT tokens, and pool retention across publishes -- JsonDocument::to() always calls clear(), which destroys pools, so "retain pools by clearing the root object" needs a string-pool lifetime analysis first. --- src/helpers/MQTTMessageBuilder.cpp | 23 ++--- src/helpers/MQTTMessageBuilder.h | 8 ++ src/helpers/bridges/MQTTBridge.cpp | 148 +++++++++++++++++------------ src/helpers/bridges/MQTTBridge.h | 50 ++++++---- 4 files changed, 142 insertions(+), 87 deletions(-) diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index 3ec13277..ebe05120 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -182,10 +182,9 @@ int MQTTMessageBuilder::buildPacketJSON( } // Convert packet to hex - // MAX_TRANS_UNIT is 255 bytes, hex = 510 chars, but allow for larger with headers - char raw_hex[1024]; + char raw_hex[WIRE_HEX_SCRATCH_SIZE]; packetToHex(packet, raw_hex, sizeof(raw_hex)); - + // Get packet characteristics int packet_type = packet->getPayloadType(); const char* route_str = getRouteTypeString(packet->isRouteDirect() ? 1 : 0); @@ -258,9 +257,9 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw( strcpy(date_str, "01/01/2024"); } - // Convert raw radio data to hex (this includes radio headers) - // MAX_TRANS_UNIT is 255 bytes, hex = 510 chars, but allow for larger with headers - char raw_hex[1024]; + // Convert raw radio data to hex (this includes radio headers). bytesToHex() emits + // an empty string rather than truncating if raw_len exceeds the protocol maximum. + char raw_hex[WIRE_HEX_SCRATCH_SIZE]; bytesToHex(raw_data, raw_len, raw_hex, sizeof(raw_hex)); // Get packet characteristics from the parsed packet @@ -314,10 +313,9 @@ int MQTTMessageBuilder::buildRawJSON( formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, timezone, timestamp, sizeof(timestamp)); // Convert packet to hex - // MAX_TRANS_UNIT is 255, so max hex size is 510 chars + null = 511 bytes - char raw_hex[1024]; + char raw_hex[WIRE_HEX_SCRATCH_SIZE]; packetToHex(packet, raw_hex, sizeof(raw_hex)); - + return buildRawMessage(origin, origin_id, timestamp, raw_hex, buffer, buffer_size); } @@ -356,9 +354,12 @@ void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex hex[0] = '\0'; // Serialize full on-air/wire format using Packet::writeTo() // This includes header, transport codes (if present), path_len, path, and payload - uint8_t raw_buf[512]; + uint8_t raw_buf[WIRE_SCRATCH_SIZE]; + // writeTo() neither bounds-checks nor can report an overrun (it returns uint8_t), + // so reject an over-long packet on getRawLength() before writing anything. + if (packet->getRawLength() > (int)sizeof(raw_buf)) return; uint8_t raw_len = packet->writeTo(raw_buf); - if (raw_len == 0 || raw_len > sizeof(raw_buf)) return; + if (raw_len == 0) return; // Check if hex buffer is large enough (2 hex chars per byte + null terminator) if (hex_size < (size_t)raw_len * 2 + 1) return; diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index 2d1fc805..7ef35a1a 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -21,6 +21,14 @@ */ class MQTTMessageBuilder { public: + // Wire-format scratch sizing, from the protocol maximum: Packet::writeTo() returns + // uint8_t, so MAX_TRANS_UNIT is the hard ceiling, and hex is 2 chars/byte + NUL. + static const size_t WIRE_SCRATCH_SIZE = MAX_TRANS_UNIT; + static const size_t WIRE_HEX_SCRATCH_SIZE = 2 * MAX_TRANS_UNIT + 1; + + static_assert(1 + 4 + 1 + MAX_PATH_SIZE + MAX_PACKET_PAYLOAD <= MAX_TRANS_UNIT, + "serialized packet no longer fits MAX_TRANS_UNIT — resize the wire scratch buffers"); + /** * Format the MQTT JSON `timestamp` field (same rule for status, packet, raw). * Always UTC with an explicit "+00:00" offset, ISO-8601 diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 9f1ecab3..eafa2321 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -177,6 +177,37 @@ static void psram_free(void* ptr) { #endif } +static void* psram_realloc(void* ptr, size_t new_size) { + if (new_size == 0) { + psram_free(ptr); + return nullptr; + } +#if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) + void* p = heap_caps_realloc(ptr, new_size, MALLOC_CAP_SPIRAM); + if (p != nullptr) return p; + // A block that fell back to internal DRAM on allocation (PSRAM exhausted) cannot + // be grown in PSRAM; retry there rather than reporting failure. + return heap_caps_realloc(ptr, new_size, MALLOC_CAP_INTERNAL); +#else + return realloc(ptr, new_size); +#endif +} + +// Shared JSON document pools follow the same PSRAM-first policy as the bridge's +// text buffers. ArduinoJson calls reallocate() when shrinking its pool list and +// asserts the result is non-null for a shrink, which both branches above satisfy. +void* MQTTBridge::JsonScratchAllocator::allocate(size_t size) { + return psram_malloc(size); +} + +void MQTTBridge::JsonScratchAllocator::deallocate(void* ptr) { + psram_free(ptr); +} + +void* MQTTBridge::JsonScratchAllocator::reallocate(void* ptr, size_t new_size) { + return psram_realloc(ptr, new_size); +} + // Time (millis()) when WiFi was last seen connected; 0 when disconnected. Used for get wifi.status uptime. static unsigned long s_wifi_connected_at = 0; @@ -609,7 +640,7 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg #endif _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0), #if defined(BOARD_HAS_PSRAM) - _publish_json_buffer(nullptr), _status_json_buffer(nullptr), + _json_scratch_buffer(nullptr), #endif _identity(identity), _cached_has_connected_slots(false), @@ -625,7 +656,7 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg _last_slot_reconnect_ms(0) #ifdef ESP_PLATFORM , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), - _mqtt_task_stack(nullptr), _packet_queue_storage(nullptr) + _packet_queue_storage(nullptr) #else , _queue_head(0), _queue_tail(0) #endif @@ -712,8 +743,8 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg #if !defined(BOARD_HAS_PSRAM) memset(_last_raw_data, 0, sizeof(_last_raw_data)); #endif - // JSON document scratch space is now a StaticJsonDocument inline class member — - // no heap allocation needed; reused via doc.clear() on every publish. + // The shared JSON document needs no setup here: its pools are allocated lazily on + // the first publish through _json_allocator and released by releaseRuntimeBuffers(). } void MQTTBridge::allocateRuntimeBuffers() { @@ -723,14 +754,11 @@ void MQTTBridge::allocateRuntimeBuffers() { // next begin() will retry only the missing buffer. _last_raw_data = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( _last_raw_data, LAST_RAW_DATA_SIZE, psram_malloc)); - _publish_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( - _publish_json_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc)); - _status_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( - _status_json_buffer, STATUS_JSON_BUFFER_SIZE, psram_malloc)); - MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s publish=%s status=%s", + _json_scratch_buffer = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + _json_scratch_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc)); + MQTT_DEBUG_PRINTLN("Runtime buffers: raw=%s json=%s", _last_raw_data ? "PSRAM" : "unavailable", - _publish_json_buffer ? "PSRAM" : "stack fallback", - _status_json_buffer ? "PSRAM" : "stack fallback"); + _json_scratch_buffer ? "PSRAM" : "stack fallback"); #endif #if defined(WITH_MQTT_NEIGHBORS) @@ -750,12 +778,15 @@ void MQTTBridge::releaseRuntimeBuffers() { #if defined(BOARD_HAS_PSRAM) _last_raw_data = static_cast(MQTTRuntimeBufferLifecycle::release( _last_raw_data, psram_free)); - _publish_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( - _publish_json_buffer, psram_free)); - _status_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( - _status_json_buffer, psram_free)); + _json_scratch_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( + _json_scratch_buffer, psram_free)); #endif + // Drop the shared document's pools with the buffers. clear() destroys every pool + // and resets the list to its inline array; the next publish reallocates. Holding + // 4 KB of pool across a stopped bridge is pure overhead. + _json_scratch_doc.clear(); + #if defined(WITH_MQTT_NEIGHBORS) // Paired with the unconditional allocation in allocateRuntimeBuffers(). _neighbors_json_buffer = static_cast(MQTTRuntimeBufferLifecycle::release( @@ -961,9 +992,8 @@ void MQTTBridge::begin() { #define MQTT_TASK_PRIORITY 1 #endif - // Task stack: use dynamic allocation (internal RAM). PSRAM stack was disabled because it - // causes resets on some boards (e.g. Heltec V4) when the task runs from PSRAM stack. - _mqtt_task_stack = nullptr; + // Task stack: dynamic allocation (internal RAM). A PSRAM-backed stack was tried and + // reverted — it resets some boards (e.g. Heltec V4) when the task runs from PSRAM. _mqtt_task_handle = nullptr; // Clear the cooperative-stop handshake before the new task starts reading it. // deliverStop() leaves _stop_requested latched true after a stop cycle, so a @@ -982,8 +1012,6 @@ void MQTTBridge::begin() { if (create_result != pdPASS) _mqtt_task_handle = nullptr; if (_mqtt_task_handle == nullptr) { MQTT_DEBUG_PRINTLN("Failed to create MQTT task!"); - psram_free(_mqtt_task_stack); - _mqtt_task_stack = nullptr; vQueueDelete(_packet_queue_handle); _packet_queue_handle = nullptr; #if defined(BOARD_HAS_PSRAM) @@ -1083,7 +1111,7 @@ void MQTTBridge::end() { #endif // Timezone is inline class storage (_timezone_storage) — nothing to delete. - // JSON documents are StaticJsonDocument inline members — no heap to free. + // The shared JSON document's pools were freed by releaseRuntimeBuffers() above. _initialized = false; _slots_setup_done = false; // Reset so deferred setup runs again on next begin() MQTT_DEBUG_PRINTLN("MQTT Bridge stopped (%s)", @@ -1137,10 +1165,6 @@ void MQTTBridge::LifecycleOps::releaseResources() { // dynamically-allocated stack/TCB in the idle task. b->_mqtt_task_handle = nullptr; - // Free the PSRAM task stack (nullptr for dynamic tasks — no-op). - psram_free(b->_mqtt_task_stack); - b->_mqtt_task_stack = nullptr; - // Drain and delete the FreeRTOS packet queue (value-copied packets, no // external pointers to clean up). Safe on Core 1: not a TLS resource. if (b->_packet_queue_handle != nullptr) { @@ -1585,7 +1609,7 @@ void MQTTBridge::initSlotClients() { updateCachedConnectionStatus(); // bool store — safe from this (esp-mqtt) task // This callback runs on the client's esp-mqtt event task, not the bridge // task. Do NOT build/publish status here: publishStatusToSlot() writes the - // shared _status_json_doc/_status_json_buffer/_origin that the periodic + // shared _json_scratch_doc/_json_scratch_buffer/_origin that the periodic // publishStatus() uses on the bridge task, and two slots' callbacks could // race each other over them. Marshal the publish onto the bridge task via a // per-slot flag (see mqttTaskLoop consumer / A2). @@ -2169,7 +2193,7 @@ bool MQTTBridge::createSlotAuthToken(int index) { return false; } -bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload, bool retained, uint8_t qos) { +bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload, size_t payload_len, bool retained, uint8_t qos) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; if (!slot.client || !slot.connected) { @@ -2197,7 +2221,7 @@ bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload // tracking). Negative values (-1 write/failure) are the only actual failures; the queue // retry/drop path below handles them. bool async = (qos > 0); - int result = slot.client->publish(topic, qos, retained, payload, strlen(payload), async); + int result = slot.client->publish(topic, qos, retained, payload, (int)payload_len, async); if (result < 0) { // QoS0 packet/raw publishes are best-effort and may be retried from the // bridge queue; avoid logging transient first-attempt failures here. @@ -2214,11 +2238,11 @@ bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload return true; } -bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, bool retained, uint8_t qos) { +bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, size_t payload_len, bool retained, uint8_t qos) { bool published = false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].client && _slots[i].connected) { - if (publishToSlot(i, topic, payload, retained, qos)) { + if (publishToSlot(i, topic, payload, payload_len, retained, qos)) { published = true; } } @@ -2280,15 +2304,16 @@ void MQTTBridge::publishStatusToSlot(int index) { } // Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure. - // _status_json_doc/_status_json_buffer/_origin are shared with publishStatus(); - // both callers run only on the bridge task (this function is reached solely via - // the _status_publish_pending consumer in mqttTaskLoop, never from the onConnect - // callback thread — see A2), so the accesses are serialized and need no mutex. + // _json_scratch_doc/_json_scratch_buffer/_origin are shared with publishStatus() and + // with the packet/raw paths; every one of them runs only on the bridge task (this + // function is reached solely via the _status_publish_pending consumer in + // mqttTaskLoop, never from the onConnect callback thread — see A2), so the accesses + // are serialized and need no mutex. #if defined(BOARD_HAS_PSRAM) char fallback_status_buffer[STATUS_JSON_BUFFER_SIZE]; - char* json_buffer = (_status_json_buffer != nullptr) ? _status_json_buffer : fallback_status_buffer; + char* json_buffer = (_json_scratch_buffer != nullptr) ? _json_scratch_buffer : fallback_status_buffer; #else - char* json_buffer = _status_json_buffer; + char* json_buffer = _json_scratch_buffer; #endif char origin_id[65]; @@ -2339,7 +2364,7 @@ void MQTTBridge::publishStatusToSlot(int index) { int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); int len = MQTTMessageBuilder::buildStatusMessage( - _status_json_doc, + _json_scratch_doc, _origin, origin_id, _board_model, _firmware_version, radio_info, client_version, "online", timestamp, json_buffer, STATUS_JSON_BUFFER_SIZE, battery_mv, uptime_secs, errors, _queue_count, noise_floor, @@ -2354,7 +2379,7 @@ void MQTTBridge::publishStatusToSlot(int index) { // on-connect status must not force retain=true. Custom slots default to // non-retained here too, keeping both status paths consistent. bool use_retain = slot.preset ? slot.preset->allow_retain : false; - int result = slot.client->publish(status_topic, 1, use_retain, json_buffer, strlen(json_buffer)); + int result = slot.client->publish(status_topic, 1, use_retain, json_buffer, len); if (result <= 0) { MQTT_DEBUG_PRINTLN("MQTT%d status publish failed", index + 1); } @@ -3144,12 +3169,12 @@ bool MQTTBridge::publishStatus() { refreshOriginFromPrefs(); // Reuse pre-allocated buffer to avoid heap alloc/free churn under memory pressure. - // _status_json_buffer and _last_raw_data are both Core 0-owned; no mutex needed. + // _json_scratch_buffer and _last_raw_data are both Core 0-owned; no mutex needed. #if defined(BOARD_HAS_PSRAM) char fallback_status_buffer[STATUS_JSON_BUFFER_SIZE]; - char* json_buffer = (_status_json_buffer != nullptr) ? _status_json_buffer : fallback_status_buffer; + char* json_buffer = (_json_scratch_buffer != nullptr) ? _json_scratch_buffer : fallback_status_buffer; #else - char* json_buffer = _status_json_buffer; + char* json_buffer = _json_scratch_buffer; #endif char origin_id[65]; char timestamp[40]; @@ -3199,7 +3224,7 @@ bool MQTTBridge::publishStatus() { int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); int len = MQTTMessageBuilder::buildStatusMessage( - _status_json_doc, + _json_scratch_doc, _origin, origin_id, _board_model, _firmware_version, radio_info, client_version, "online", timestamp, json_buffer, STATUS_JSON_BUFFER_SIZE, battery_mv, uptime_secs, errors, _queue_count, noise_floor, @@ -3217,7 +3242,7 @@ bool MQTTBridge::publishStatus() { if (buildTopicForSlot(i, MSG_STATUS, topic, sizeof(topic))) { any_slot_wants_status = true; bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false; - if (publishToSlot(i, topic, json_buffer, use_retain, 1)) { + if (publishToSlot(i, topic, json_buffer, (size_t)len, use_retain, 1)) { published = true; } } @@ -3291,15 +3316,15 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, char json_buffer_stack[PUBLISH_JSON_BUFFER_SIZE]; char* active_buffer; size_t active_buffer_size; - if (_publish_json_buffer != nullptr) { - active_buffer = _publish_json_buffer; + if (_json_scratch_buffer != nullptr) { + active_buffer = _json_scratch_buffer; active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; } else { active_buffer = json_buffer_stack; active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; } #else - char* active_buffer = _publish_json_buffer; + char* active_buffer = _json_scratch_buffer; const size_t active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; #endif char origin_id[65]; @@ -3317,33 +3342,36 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, if (raw_data && raw_len > 0) { float score = (_radio && !is_tx) ? _radio->packetScore(snr, raw_len) : NAN; len = MQTTMessageBuilder::buildPacketJSONFromRaw( - _packet_json_doc, + _json_scratch_doc, raw_data, raw_len, packet, is_tx, _origin, origin_id, snr, rssi, score, _timezone, active_buffer, active_buffer_size ); } else if (!is_tx && _last_raw_data && _last_raw_len > 0 && (millis() - _last_raw_timestamp) < 1000) { float score = _radio ? _radio->packetScore(_last_snr, _last_raw_len) : NAN; len = MQTTMessageBuilder::buildPacketJSONFromRaw( - _packet_json_doc, + _json_scratch_doc, _last_raw_data, _last_raw_len, packet, is_tx, _origin, origin_id, _last_snr, _last_rssi, score, _timezone, active_buffer, active_buffer_size ); } else { // Reconstruct wire-format bytes from packet (same as MQTTMessageBuilder::packetToHex). - // This path is used on non-PSRAM boards where raw_data is not stored in the queue, - // and ensures the "raw" hex field and SNR/RSSI are accurate in the JSON output. - uint8_t reconstructed[512]; - uint8_t rlen = packet->writeTo(reconstructed); + // Reached when the queued item carried no captured raw frame, so the "raw" hex field + // is re-serialized rather than dropped. Length-guarded for the same reason. + uint8_t reconstructed[MQTTMessageBuilder::WIRE_SCRATCH_SIZE]; + uint8_t rlen = 0; + if (packet->getRawLength() <= (int)sizeof(reconstructed)) { + rlen = packet->writeTo(reconstructed); + } if (rlen > 0) { float score = (_radio && !is_tx) ? _radio->packetScore(snr, rlen) : NAN; len = MQTTMessageBuilder::buildPacketJSONFromRaw( - _packet_json_doc, + _json_scratch_doc, reconstructed, rlen, packet, is_tx, _origin, origin_id, snr, rssi, score, _timezone, active_buffer, active_buffer_size ); } else { len = MQTTMessageBuilder::buildPacketJSON( - _packet_json_doc, + _json_scratch_doc, packet, is_tx, _origin, origin_id, _timezone, active_buffer, active_buffer_size ); } @@ -3359,7 +3387,7 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, if ((eligible_slots & static_cast(1u << i)) != 0 && _slots[i].enabled && _slots[i].client && _slots[i].connected) { if (buildTopicForSlot(i, MSG_PACKETS, topic, sizeof(topic))) { - if (publishToSlot(i, topic, active_buffer, false)) { + if (publishToSlot(i, topic, active_buffer, (size_t)len, false)) { published = true; } } @@ -3391,15 +3419,15 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) { char json_buffer_stack[PUBLISH_JSON_BUFFER_SIZE]; char* active_buffer; size_t active_buffer_size; - if (_publish_json_buffer != nullptr) { - active_buffer = _publish_json_buffer; + if (_json_scratch_buffer != nullptr) { + active_buffer = _json_scratch_buffer; active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; } else { active_buffer = json_buffer_stack; active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; } #else - char* active_buffer = _publish_json_buffer; + char* active_buffer = _json_scratch_buffer; const size_t active_buffer_size = PUBLISH_JSON_BUFFER_SIZE; #endif char origin_id[65]; @@ -3418,7 +3446,7 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) { if ((eligible_slots & static_cast(1u << i)) != 0 && _slots[i].enabled && _slots[i].client && _slots[i].connected) { if (buildTopicForSlot(i, MSG_RAW, topic, sizeof(topic))) { - if (publishToSlot(i, topic, active_buffer, false)) { + if (publishToSlot(i, topic, active_buffer, (size_t)len, false)) { published = true; } } @@ -3468,7 +3496,7 @@ bool MQTTBridge::publishNeighbors() { // Neighbor snapshots are periodically refreshed. Publish synchronously // at QoS 0 to avoid the QoS 1 outbox, retaining where the broker allows. bool use_retain = _slots[i].preset ? _slots[i].preset->allow_retain : false; - if (publishToSlot(i, topic, _neighbors_json_buffer, use_retain, 0)) { + if (publishToSlot(i, topic, _neighbors_json_buffer, _neighbors_publish_len, use_retain, 0)) { published = true; } } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 143aae36..d1211fc6 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -176,9 +176,6 @@ private: #ifdef ESP_PLATFORM QueueHandle_t _packet_queue_handle; TaskHandle_t _mqtt_task_handle; - // PSRAM-backed task stack; TCB kept in internal RAM - StackType_t* _mqtt_task_stack; // nullptr if using dynamic task creation - StaticTask_t _mqtt_task_tcb; // Packet queue storage: PSRAM heap on PSRAM boards, inline array on non-PSRAM boards. // Using xQueueCreateStatic with inline storage eliminates a separate heap allocation. uint8_t* _packet_queue_storage; @@ -285,17 +282,22 @@ private: float _last_rssi; unsigned long _last_raw_timestamp; - // JSON publish/status serialization buffers — reused for every publish (no alloc/free churn). - // On PSRAM boards: heap pointer into PSRAM to save internal heap. On non-PSRAM: inline in - // class object so these allocations don't interleave with large TLS buffers at startup. + // One JSON serialization buffer shared by every publish path — packet, raw, and + // status all serialize on the bridge task (Core 0), so they are never in flight at + // the same time and a second buffer bought nothing. Reused rather than reallocated + // per publish (no alloc/free churn). On PSRAM boards: heap pointer into PSRAM to save + // internal heap. On non-PSRAM: inline in the class object so the allocation doesn't + // interleave with large TLS buffers at startup. static const size_t PUBLISH_JSON_BUFFER_SIZE = 2048; + // Status keeps its own smaller ceiling: raising it would change which oversized + // status documents get published instead of dropped. static const size_t STATUS_JSON_BUFFER_SIZE = 768; + static_assert(STATUS_JSON_BUFFER_SIZE <= PUBLISH_JSON_BUFFER_SIZE, + "status payloads serialize into the shared publish buffer"); #if defined(BOARD_HAS_PSRAM) - char* _publish_json_buffer; - char* _status_json_buffer; + char* _json_scratch_buffer; #else - char _publish_json_buffer[PUBLISH_JSON_BUFFER_SIZE]; - char _status_json_buffer[STATUS_JSON_BUFFER_SIZE]; + char _json_scratch_buffer[PUBLISH_JSON_BUFFER_SIZE]; #endif #if defined(WITH_MQTT_NEIGHBORS) @@ -318,10 +320,23 @@ private: std::atomic _neighbors_secs_until_next; #endif - // JSON document scratch space — inline StaticJsonDocument keeps the pool off the MQTT - // task stack and eliminates two separate heap allocations (fragmentation reduction). - StaticJsonDocument _packet_json_doc; - StaticJsonDocument _status_json_doc; + // Routes the shared document's pools to PSRAM where the board has it, matching the + // neighbors document's allocator in MyMesh.cpp. ArduinoJson's default allocator is + // plain malloc(), which puts every per-publish pool block (4096 bytes here) in + // internal DRAM next to the mbedTLS working set. + struct JsonScratchAllocator : ArduinoJson::Allocator { + void* allocate(size_t size) override; + void deallocate(void* ptr) override; + void* reallocate(void* ptr, size_t new_size) override; + }; + JsonScratchAllocator _json_allocator; + + // Shared by the packet/raw/status builders, like _json_scratch_buffer above. + // Declared after _json_allocator so the allocator is constructed first. + // This was a StaticJsonDocument described as an inline pool; under ArduinoJson 7 + // that is a deprecated empty subclass of JsonDocument whose template argument only + // feeds capacity(), so the object is 64 bytes and every pool comes from the allocator. + JsonDocument _json_scratch_doc{&_json_allocator}; // Memory pressure monitoring (per-publish skip; see publishPacket()). // The broader fragmentation-recovery machinery was removed in Phase 4 of @@ -400,8 +415,11 @@ private: void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted); bool createSlotAuthToken(int index); // Create/renew JWT token for a slot unsigned long slotTokenLifetime(int index) const; // effective JWT lifetime (preset/default minus slot stagger), seconds - bool publishToSlot(int index, const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); - bool publishToAllSlots(const char* topic, const char* payload, bool retained = false, uint8_t qos = 0); + // payload_len is the serialized length the builder already returned. Every caller + // knows it, and passing it avoids re-scanning up to 2 KB of JSON per destination + // slot (and up to NEIGHBORS_JSON_BUFFER_SIZE per neighbor snapshot). + bool publishToSlot(int index, const char* topic, const char* payload, size_t payload_len, bool retained = false, uint8_t qos = 0); + bool publishToAllSlots(const char* topic, const char* payload, size_t payload_len, bool retained = false, uint8_t qos = 0); void publishStatusToSlot(int index); void updateCachedConnectionStatus(); From c833da10e98778d556d32df4bc35bf11f894a422 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 09:16:47 -0700 Subject: [PATCH 03/40] perf(mqtt): allocate slot clients on demand initSlotClients() created a PsychicMqttClient for every one of RUNTIME_MQTT_SLOTS at begin(), without consulting slot.enabled or the active cap -- even though presets are applied and _max_active_slots is computed earlier in the same function. RUNTIME_MQTT_SLOTS is deliberately cap+1 (6/5 with PSRAM, 3/2 without), so at least one client was always unusable, and a lightly-configured board wasted several. Replaced with ensureSlotClient(index), called from setupSlot() -- i.e. only for a slot that is enabled, inside the cap, and ready to connect. A client that never reaches setupSlot() is completely inert: the reconnect ladder is gated on initial_connect_done, which only setupSlot() sets. Retained for the bridge lifetime as before, so reconfigure/reconnect still reuse one mbedTLS context; that context is built by connect(), not by the constructor, so deferring costs nothing but the 1284-byte object. Internal DRAM saved, by configured slot count: non-PSRAM (3 runtime / 2 cap) 1 configured 2568 B 2-3 1284 B PSRAM (6 runtime / 5 cap) 1 configured 6420 B 2 5136 B 5-6 1284 B A BOARD_HAS_PSRAM board whose PSRAM fails to init gets cap 2 against 6 runtime slots, so it saves at least 5136 B -- on the board that just lost its PSRAM. Two gates used "client != nullptr" as a proxy for "configured". Under eager allocation that conjunct was always true and therefore harmless; with lazy allocation it would have made shouldQueuePacketType() drop every packet received before the post-NTP-sync slot setup, which is precisely the window the queue exists to cover. Both now key on slot.enabled, matching the documented intent above eligiblePacketSlots() that a configured-but- disconnected broker is still a target. formatSlotDiagReply() gains the !isSlotReady() -> "wait" branch that get mqtt.status and getSlotStatusSnapshot() already have; that state previously fell through to "disc" and read as a network fault when it is really a missing token/IATA/credential. "no client" now means only what its name says: the slot is ready but the client could not be allocated. That state is newly reachable, so the allocation uses new (std::nothrow) -- this framework enables C++ exceptions, and a throwing new on exhaustion would panic the node instead of degrading one slot. flash non-PSRAM 1592573 -> 1592697 B (+124) PSRAM 1555001 -> 1555109 B (+108) 267/267 native tests pass; both observer envs build clean. --- src/helpers/bridges/MQTTBridge.cpp | 204 ++++++++++++++++------------- src/helpers/bridges/MQTTBridge.h | 11 +- 2 files changed, 120 insertions(+), 95 deletions(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index eafa2321..556936a1 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #ifdef WITH_SNMP @@ -505,7 +506,13 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index) return; } else if (!slot.enabled) { state = "inactive"; + } else if (!b->isSlotReady(slot_index)) { + // Same classification as `get mqtt.status` and getSlotStatusSnapshot(): the slot + // is configured but missing a token/IATA/credential, so it was never set up and + // has no client yet. Previously reported "disc", which read as a network fault. + state = "wait"; } else if (!slot.client) { + // Ready to connect but the client object could not be allocated. state = "no client"; } else if (slot.connected) { state = "ok"; @@ -1033,10 +1040,11 @@ void MQTTBridge::begin() { // NOTE: Slot setup deferred until after NTP sync in loop() #endif - // Allocate persistent MQTT client objects once. They live for the bridge's - // lifetime so reconfigure/reconnect paths reuse the same mbedTLS context - // instead of churning ~40 KB of internal heap per cycle. - initSlotClients(); + // MQTT client objects are NOT allocated here. setupSlot() creates one on a slot's + // first setup, so unconfigured and capped-off slots never cost their ~1.3 KB of + // internal DRAM. Once created a client lives for the bridge's lifetime, so the + // reconfigure/reconnect paths still reuse the same mbedTLS context instead of + // churning ~40 KB of internal heap per cycle. // Sync the lifecycle Coordinator to Running now that all resources exist and // the task is created. Driven only on the success path: the failure rollbacks @@ -1570,85 +1578,96 @@ void MQTTBridge::mqttTaskLoop() { // Slot management // --------------------------------------------------------------------------- -// Allocate one PsychicMqttClient per slot and register its persistent callbacks. -// Called exactly once per bridge lifetime from begin(); the objects live until -// destroySlotClients(). Reconfiguring a slot (preset change, JWT renewal, -// reconnect) reuses the same client — no delete/new cycles, so the mbedTLS -// context and its ~40 KB of internal-heap buffers are allocated once instead -// of every reconfigure. -void MQTTBridge::initSlotClients() { - for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - MQTTSlot& slot = _slots[i]; - if (slot.client != nullptr) continue; +// Allocate this slot's PsychicMqttClient and register its persistent callbacks. +// Called from setupSlot(), i.e. only for a slot that is enabled, within the active +// cap, and ready to connect — a client is ~1.3 KB of internal DRAM and does nothing +// at all until setupSlot() runs (the reconnect ladder is gated on +// initial_connect_done), so slots that are unconfigured or capped off never get one. +// +// Once created the object lives until destroySlotClients(): reconfiguring a slot +// (preset change, JWT renewal, reconnect) reuses it, so the mbedTLS context and its +// ~40 KB of internal-heap buffers are allocated once instead of every reconfigure. +// That context is created by connect(), not by this constructor, so deferring the +// allocation to first use costs nothing beyond the object itself. +bool MQTTBridge::ensureSlotClient(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + MQTTSlot& slot = _slots[index]; + if (slot.client != nullptr) return true; - slot.client = new PsychicMqttClient(); - slot.client->setAutoReconnect(false); // we handle reconnect with our own backoff - - const int index = i; // capture a fresh copy so lambdas refer to the right slot - slot.client->onConnect([this, index](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT%d connected", index + 1); - _slots[index].connected = true; - // NOTE: reconnect_backoff / max_backoff_failures are NOT reset here. - // A CONNACK alone doesn't prove the link is healthy — a broker that - // accepts and then drops within seconds would reset the ladder every - // cycle and retry at the 10 s rung forever, and each retry is a full - // TLS session alloc/free (~40 KB of internal-heap churn, a known - // fragmentation driver). The ladder is instead cleared by - // maintainSlotConnection() once the connection has stayed up for - // BACKOFF_STABLE_RESET_MS, so flapping endpoints keep their earned - // backoff level. The breaker itself does clear now: while connected - // the diag/status must not claim the slot gave up, and the next - // disconnect should be governed by the (still-elevated) ladder. - _slots[index].connected_at_ms = millis(); - _slots[index].circuit_breaker_tripped = false; - _slots[index].last_tls_err = 0; - _slots[index].last_tls_stack_err = 0; - _slots[index].last_sock_errno = 0; - _slots[index].last_error_time = 0; - _slots[index].current_outage_started_ms = 0; // clear current-outage timer for AlertReporter - updateCachedConnectionStatus(); // bool store — safe from this (esp-mqtt) task - // This callback runs on the client's esp-mqtt event task, not the bridge - // task. Do NOT build/publish status here: publishStatusToSlot() writes the - // shared _json_scratch_doc/_json_scratch_buffer/_origin that the periodic - // publishStatus() uses on the bridge task, and two slots' callbacks could - // race each other over them. Marshal the publish onto the bridge task via a - // per-slot flag (see mqttTaskLoop consumer / A2). - _status_publish_pending[index] = true; - }); - slot.client->onDisconnect([this, index](bool sessionPresent) { - MQTT_DEBUG_PRINTLN("MQTT%d disconnected", index + 1); - _slots[index].disconnect_count++; - if (_slots[index].first_disconnect_time == 0) { - _slots[index].first_disconnect_time = millis(); - } - if (_slots[index].current_outage_started_ms == 0) { - _slots[index].current_outage_started_ms = millis(); - } - _slots[index].connected = false; - _slots[index].connected_at_ms = 0; // stability clock only runs while connected - updateCachedConnectionStatus(); - }); - slot.client->onError([this, index](esp_mqtt_error_codes error) { - _slots[index].last_tls_err = error.esp_tls_last_esp_err; - _slots[index].last_tls_stack_err = error.esp_tls_stack_err; - _slots[index].last_sock_errno = error.esp_transport_sock_errno; - _slots[index].last_error_time = millis(); - if (error.error_type == MQTT_ERROR_TYPE_CONNECTION_REFUSED) { - // Broker rejected the MQTT CONNECT itself — not a transport failure. - // return code: 1=protocol, 2=client-id rejected, 3=server unavailable, - // 4=bad username/password, 5=not authorized. Codes 3/4/5 point at a - // server-side lockout or auth problem rather than the network. - MQTT_DEBUG_PRINTLN("MQTT%d connection refused by broker (return code=%d)", - index + 1, (int)error.connect_return_code); - } else if (error.esp_tls_last_esp_err != 0 || error.esp_tls_stack_err != 0 || error.esp_transport_sock_errno != 0) { - MQTT_DEBUG_PRINTLN("MQTT%d error: tls=%d, tls_stack=%d, sock=%d, type=%d", - index + 1, error.esp_tls_last_esp_err, error.esp_tls_stack_err, - error.esp_transport_sock_errno, error.error_type); - } else { - MQTT_DEBUG_PRINTLN("MQTT%d error: type=%d", index + 1, error.error_type); - } - }); + // nothrow: this framework builds with C++ exceptions enabled, so a plain new would + // throw on exhaustion and panic the node. A slot that cannot get a client should + // degrade to the "no client" diag state instead. + slot.client = new (std::nothrow) PsychicMqttClient(); + if (slot.client == nullptr) { + MQTT_DEBUG_PRINTLN("MQTT%d: out of memory allocating client", index + 1); + return false; } + slot.client->setAutoReconnect(false); // we handle reconnect with our own backoff + + slot.client->onConnect([this, index](bool sessionPresent) { + MQTT_DEBUG_PRINTLN("MQTT%d connected", index + 1); + _slots[index].connected = true; + // NOTE: reconnect_backoff / max_backoff_failures are NOT reset here. + // A CONNACK alone doesn't prove the link is healthy — a broker that + // accepts and then drops within seconds would reset the ladder every + // cycle and retry at the 10 s rung forever, and each retry is a full + // TLS session alloc/free (~40 KB of internal-heap churn, a known + // fragmentation driver). The ladder is instead cleared by + // maintainSlotConnection() once the connection has stayed up for + // BACKOFF_STABLE_RESET_MS, so flapping endpoints keep their earned + // backoff level. The breaker itself does clear now: while connected + // the diag/status must not claim the slot gave up, and the next + // disconnect should be governed by the (still-elevated) ladder. + _slots[index].connected_at_ms = millis(); + _slots[index].circuit_breaker_tripped = false; + _slots[index].last_tls_err = 0; + _slots[index].last_tls_stack_err = 0; + _slots[index].last_sock_errno = 0; + _slots[index].last_error_time = 0; + _slots[index].current_outage_started_ms = 0; // clear current-outage timer for AlertReporter + updateCachedConnectionStatus(); // bool store — safe from this (esp-mqtt) task + // This callback runs on the client's esp-mqtt event task, not the bridge + // task. Do NOT build/publish status here: publishStatusToSlot() writes the + // shared _json_scratch_doc/_json_scratch_buffer/_origin that the periodic + // publishStatus() uses on the bridge task, and two slots' callbacks could + // race each other over them. Marshal the publish onto the bridge task via a + // per-slot flag (see mqttTaskLoop consumer / A2). + _status_publish_pending[index] = true; + }); + slot.client->onDisconnect([this, index](bool sessionPresent) { + MQTT_DEBUG_PRINTLN("MQTT%d disconnected", index + 1); + _slots[index].disconnect_count++; + if (_slots[index].first_disconnect_time == 0) { + _slots[index].first_disconnect_time = millis(); + } + if (_slots[index].current_outage_started_ms == 0) { + _slots[index].current_outage_started_ms = millis(); + } + _slots[index].connected = false; + _slots[index].connected_at_ms = 0; // stability clock only runs while connected + updateCachedConnectionStatus(); + }); + slot.client->onError([this, index](esp_mqtt_error_codes error) { + _slots[index].last_tls_err = error.esp_tls_last_esp_err; + _slots[index].last_tls_stack_err = error.esp_tls_stack_err; + _slots[index].last_sock_errno = error.esp_transport_sock_errno; + _slots[index].last_error_time = millis(); + if (error.error_type == MQTT_ERROR_TYPE_CONNECTION_REFUSED) { + // Broker rejected the MQTT CONNECT itself — not a transport failure. + // return code: 1=protocol, 2=client-id rejected, 3=server unavailable, + // 4=bad username/password, 5=not authorized. Codes 3/4/5 point at a + // server-side lockout or auth problem rather than the network. + MQTT_DEBUG_PRINTLN("MQTT%d connection refused by broker (return code=%d)", + index + 1, (int)error.connect_return_code); + } else if (error.esp_tls_last_esp_err != 0 || error.esp_tls_stack_err != 0 || error.esp_transport_sock_errno != 0) { + MQTT_DEBUG_PRINTLN("MQTT%d error: tls=%d, tls_stack=%d, sock=%d, type=%d", + index + 1, error.esp_tls_last_esp_err, error.esp_tls_stack_err, + error.esp_transport_sock_errno, error.error_type); + } else { + MQTT_DEBUG_PRINTLN("MQTT%d error: type=%d", index + 1, error.error_type); + } + }); + return true; } void MQTTBridge::destroySlotClients() { @@ -1678,10 +1697,9 @@ void MQTTBridge::setupSlot(int index) { return; } - // Persistent client is expected to have been allocated by initSlotClients(). - // If it hasn't, we can't proceed — bail loudly rather than silently leaking. - if (slot.client == nullptr) { - MQTT_DEBUG_PRINTLN("MQTT%d: setupSlot before initSlotClients() - skipping", index + 1); + // First setup for this slot allocates its persistent client; later ones reuse it. + if (!ensureSlotClient(index)) { + MQTT_DEBUG_PRINTLN("MQTT%d: client allocation failed - skipping", index + 1); return; } @@ -1954,7 +1972,7 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns // ladder. Flapping endpoints therefore stay at their earned backoff rung // (worst case the 300 s rung / 30-minute breaker probes) instead of // hammering full TLS handshakes at the 10 s rung — see the onConnect - // handler in initSlotClients() for why this doesn't happen on CONNACK. + // handler in ensureSlotClient() for why this doesn't happen on CONNACK. if (slot.connected && (slot.reconnect_backoff != 0 || slot.max_backoff_failures != 0) && MQTTConnectionPolicy::stableConnection(static_cast(now_millis), @@ -2053,8 +2071,8 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0); if (slot_uses_jwt) { // Regenerate or refresh token, then reconnect the persistent client. - // The client object and its mbedTLS context are always live post - // initSlotClients(), so no full setup is ever needed here. + // Reaching the ladder at all means setupSlot() ran, so the client object + // and its mbedTLS context are live and no full setup is needed here. if (createSlotAuthToken(index)) { slot.client->setCredentials(_jwt_username, slot.auth_token); MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (fresh token)", index + 1); @@ -3118,7 +3136,10 @@ uint8_t MQTTBridge::eligiblePacketSlots(uint8_t packet_type, MQTTMessageType typ uint8_t eligible_slots = 0; char topic[128]; for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) { - const bool slot_enabled = _slots[i].enabled && _slots[i].client != nullptr; + // Configuration is the gate, not client allocation: a slot whose client has not + // been created yet (setupSlot() runs only after NTP sync) is still a target, so + // the packet stays queued for the bounded retry per the note above. + const bool slot_enabled = _slots[i].enabled; // Load once so a live CLI/WebConfig update cannot split this packet's // decision across two different masks. const uint16_t filter_mask = _obs->mqtt_slot_packet_filter[i]; @@ -3147,7 +3168,10 @@ bool MQTTBridge::shouldQueuePacketType(uint8_t packet_type, bool& filtered) { bool any_enabled = false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) { masks[i] = _obs->mqtt_slot_packet_filter[i]; - enabled[i] = _slots[i].enabled && _slots[i].client != nullptr; + // Configured, not allocated — see eligiblePacketSlots(). Gating on the client + // here would silently drop every packet received before the post-NTP-sync slot + // setup, which is exactly the window the queue exists to cover. + enabled[i] = _slots[i].enabled; any_enabled = any_enabled || enabled[i]; } if (!any_enabled) return false; diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index d1211fc6..850794e8 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -398,16 +398,17 @@ private: // Internal methods - slot management // Lifetime model (Phase 1 of MQTT memory-defrag): - // - initSlotClients() allocates one PsychicMqttClient per slot and registers - // its persistent callbacks. Runs once per bridge lifetime in begin(). + // - ensureSlotClient() allocates this slot's PsychicMqttClient and registers its + // persistent callbacks. Called from setupSlot() on a slot's first setup, so an + // unconfigured or capped-off slot never pays for a client it cannot use. // - destroySlotClients() disconnects and deletes each client. Runs once in end(). - // - setupSlot() configures an already-allocated client (server, credentials, - // CA) and calls connect(). Safe to call multiple times to reconfigure. + // - setupSlot() ensures the client exists, then configures it (server, + // credentials, CA) and calls connect(). Safe to call again to reconfigure. // - teardownSlot() only disconnects — it never deletes the client. Leaves // the mbedTLS/transport state ready for a subsequent setupSlot(). // This avoids delete/new cycles that shed ~40 KB of mbedTLS buffers per // reconfigure and fragment the internal heap on non-PSRAM boards. - void initSlotClients(); // Allocate persistent clients + register callbacks (once) + bool ensureSlotClient(int index); // Allocate this slot's persistent client + callbacks on first use void destroySlotClients(); // Delete all persistent clients (shutdown only) void setupSlot(int index); // Configure and connect the slot's existing client void teardownSlot(int index); // Disconnect the slot's client (keeps the object alive) From 9141ad854fce703627eea781ab09e3e154dbd4ad Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 09:45:30 -0700 Subject: [PATCH 04/40] perf(mqtt): allocate slot JWT token buffers on demand MQTTSlot carried an inline char auth_token[768] -- 768 of its 1192 bytes -- for every runtime slot, whether the slot was JWT, username/password, disabled, or above the active-slot cap. Now a char* allocated by ensureSlotAuthToken() from createSlotAuthToken(), the sole writer, which runs only once a slot is confirmed to have a JWT audience. Because _slots[] is a fixed member array, this shrinks the bridge object unconditionally at construction and re-adds only what is used: MQTTSlot 1192 -> 428 B (-764) MQTTBridge non-PSRAM 12032 -> 9740 B (-2292) PSRAM 10080 -> 5496 B (-4584) The object is allocated as one contiguous block at boot, so a smaller boot-time chunk is easier to satisfy and leaves a larger contiguous remainder; JWT slots then take 768 B each as separate blocks. Net steady-state saving is 768 B per slot that never creates a token -- 768 B on a fully configured board, up to 3840 B on a PSRAM board with one JWT slot. 22 of the 29 presets are MQTT_AUTH_JWT, so this is mostly reclaiming unconfigured slots rather than non-JWT ones. Lifetime rules, which are the whole risk here: - setCredentials() stores this pointer in _mqtt_cfg rather than copying, and esp-mqtt re-reads it whenever a later connect() re-applies a dirtied config. So the buffer is freed only alongside the client, in destroySlotClients(), after the delete. MQTTSlot::broker_uri already carries an "avoids dangling pointer" comment from this same hazard with setServer(). - teardownSlot() still clears the token to an empty string but keeps the buffer: the client survives teardown and does not clear cfg->password (only setupSlot()'s reconfigure branch does). Freeing there would dangle. - Never freed per reconnect, preserving the churn-avoidance the inline buffer was there for. Allocation goes through MQTTRuntimeBufferLifecycle (already host-tested) using plain malloc, so the buffer stays in internal DRAM exactly where it was when inline. Failure propagates through createSlotAuthToken()'s existing bool. The two call sites that create a token and then test it (preset JWT in setupSlot, and the custom-slot audience path) now null-check first; the five other readers are all inside if (createSlotAuthToken(...)) success branches and need no change. destroySlotClients() releases the token unconditionally rather than after its client null-check, so a token can never outlive its slot. flash non-PSRAM 1592697 -> 1592801 B (+104) PSRAM 1555109 -> 1555213 B (+104) 267/267 native tests pass; both observer envs build clean. --- src/helpers/bridges/MQTTBridge.cpp | 71 +++++++++++++++++++++++------- src/helpers/bridges/MQTTBridge.h | 14 ++++-- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 556936a1..5d42f00f 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -692,7 +692,7 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg _slots[i].enabled = false; _slots[i].client = nullptr; _slots[i].preset = nullptr; - // auth_token[0] == '\0' after memset above — no valid token + // auth_token == nullptr after memset above — allocated on first token creation _slots[i].connected = false; _slots[i].initial_connect_done = false; _slots[i].token_expires_at = 0; @@ -1670,21 +1670,52 @@ bool MQTTBridge::ensureSlotClient(int index) { return true; } +// Allocate this slot's JWT token buffer. Called only from createSlotAuthToken(), the +// sole writer, so a slot on a non-JWT preset (or no preset at all) never allocates. +// Internal DRAM, matching where the buffer lived when it was inline in MQTTSlot. +bool MQTTBridge::ensureSlotAuthToken(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + MQTTSlot& slot = _slots[index]; + slot.auth_token = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( + slot.auth_token, AUTH_TOKEN_SIZE, malloc)); + if (slot.auth_token == nullptr) { + MQTT_DEBUG_PRINTLN("MQTT%d: out of memory allocating auth token", index + 1); + return false; + } + slot.auth_token[0] = '\0'; + return true; +} + +// Safe only once this slot's client is gone: setCredentials() gave the client this +// pointer, and esp-mqtt re-reads it from _mqtt_cfg on any later connect() that +// re-applies a dirtied config. See the MQTTSlot::auth_token comment. +void MQTTBridge::releaseSlotAuthToken(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; + MQTTSlot& slot = _slots[index]; + slot.auth_token = static_cast( + MQTTRuntimeBufferLifecycle::release(slot.auth_token, free)); + slot.token_expires_at = 0; + slot.last_token_renewal = 0; +} + void MQTTBridge::destroySlotClients() { for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { MQTTSlot& slot = _slots[i]; - if (slot.client == nullptr) continue; - - if (slot.client->connected()) { - slot.client->disconnect(); + if (slot.client != nullptr) { + if (slot.client->connected()) { + slot.client->disconnect(); + } + #ifdef ESP_PLATFORM + vTaskDelay(pdMS_TO_TICKS(50)); + #else + delay(50); + #endif + delete slot.client; + slot.client = nullptr; } - #ifdef ESP_PLATFORM - vTaskDelay(pdMS_TO_TICKS(50)); - #else - delay(50); - #endif - delete slot.client; - slot.client = nullptr; + // Unconditional: only now is the token unreachable from the client's stored + // config, and a token without a client would otherwise leak. + releaseSlotAuthToken(i); } } @@ -1727,7 +1758,7 @@ void MQTTBridge::setupSlot(int index) { cfg->username = nullptr; cfg->password = nullptr; #endif - slot.auth_token[0] = '\0'; + if (slot.auth_token) slot.auth_token[0] = '\0'; slot.connected = false; slot.token_expires_at = 0; slot.last_token_renewal = 0; @@ -1760,7 +1791,7 @@ void MQTTBridge::setupSlot(int index) { // Try to create token and connect (will succeed only if NTP synced) if (slot.preset->auth_type == MQTT_AUTH_JWT) { createSlotAuthToken(index); - if (slot.auth_token[0] != '\0') { + if (slot.auth_token && slot.auth_token[0] != '\0') { slot.client->setCredentials(_jwt_username, slot.auth_token); } } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS) { @@ -1871,9 +1902,9 @@ void MQTTBridge::setupSlot(int index) { // Custom slot authentication: JWT if audience is set, else username/password if (slot.audience[0] != '\0') { - // JWT auth for custom slot — create initial token (buffer is always inline) + // JWT auth for custom slot — create initial token (allocates the buffer) createSlotAuthToken(index); - if (slot.auth_token[0] != '\0') { + if (slot.auth_token && slot.auth_token[0] != '\0') { slot.client->setCredentials(_jwt_username, slot.auth_token); } MQTT_DEBUG_PRINTLN("MQTT%d custom broker using JWT auth (audience: %s)", index + 1, slot.audience); @@ -1903,7 +1934,9 @@ void MQTTBridge::teardownSlot(int index) { #endif } - slot.auth_token[0] = '\0'; + // Invalidate the token but keep the buffer: the client survives teardown and still + // holds this pointer in its config (see MQTTSlot::auth_token). + if (slot.auth_token) slot.auth_token[0] = '\0'; slot.connected = false; slot.initial_connect_done = false; slot.broker_uri[0] = '\0'; @@ -2172,6 +2205,10 @@ bool MQTTBridge::createSlotAuthToken(int index) { } if (!audience || audience[0] == '\0') return false; + // This slot is confirmed JWT, so it needs the token buffer. Allocated on first use + // and kept thereafter; every caller already treats false as "no usable token". + if (!ensureSlotAuthToken(index)) return false; + // Ensure JWT username is set if (_jwt_username[0] == '\0') { char public_key_hex[65]; diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 850794e8..d1c72e3f 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -86,9 +86,15 @@ private: bool connected; // Updated in callbacks bool initial_connect_done; // True after first connect() call - // JWT auth state (used by preset JWT slots and custom slots with audience set) - // Inline buffer avoids per-reconnect heap alloc/free churn (fragmentation source). - char auth_token[AUTH_TOKEN_SIZE]; // empty string = no valid token + // JWT auth state (used by preset JWT slots and custom slots with audience set). + // nullptr until this slot first creates a token, so a slot that is unconfigured, + // capped off, or on a non-JWT preset never pays for AUTH_TOKEN_SIZE. Allocated by + // ensureSlotAuthToken() and then held for the client's lifetime -- never freed per + // reconnect (alloc/free churn is a fragmentation source) and never freed on + // teardown, because setCredentials() hands this exact pointer to the client and + // esp-mqtt re-reads it whenever a later connect() re-applies a dirtied config. + // Freed only alongside the client in destroySlotClients(). + char* auth_token; // nullptr or empty string = no valid token unsigned long token_expires_at; unsigned long last_token_renewal; @@ -409,6 +415,8 @@ private: // This avoids delete/new cycles that shed ~40 KB of mbedTLS buffers per // reconfigure and fragment the internal heap on non-PSRAM boards. bool ensureSlotClient(int index); // Allocate this slot's persistent client + callbacks on first use + bool ensureSlotAuthToken(int index); // Allocate this slot's JWT token buffer on first token creation + void releaseSlotAuthToken(int index);// Free the token buffer (only with the client — see MQTTSlot) void destroySlotClients(); // Delete all persistent clients (shutdown only) void setupSlot(int index); // Configure and connect the slot's existing client void teardownSlot(int index); // Disconnect the slot's client (keeps the object alive) From b93b6b1560a08e5e707caf09d59ddd38e37a8593 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 09:47:06 -0700 Subject: [PATCH 05/40] perf(mqtt): keep slot JWT tokens in PSRAM where available ensureSlotAuthToken()/releaseSlotAuthToken() now use the bridge's existing psram_malloc/psram_free rather than malloc/free, so a JWT slot's 768-byte token comes out of PSRAM instead of internal DRAM. On PSRAM boards this is the larger half of the auth_token work: the previous commit reclaims 768 B per slot that never creates a token, while this reclaims 768 B per slot that does -- up to 3840 B of internal DRAM on a fully configured five-slot board, which is where the mbedTLS working set is competing for space. No effect on non-PSRAM boards: psram_malloc falls back to internal DRAM, so the buffer stays exactly where the previous commit left it. Same for a BOARD_HAS_PSRAM board whose PSRAM fails to initialise. Safe to move because every access is a CPU copy on the bridge task, never DMA, an ISR, or a cache-disabled window: JWTHelper memcpy's the token into this buffer, and esp-mqtt copies it out of _mqtt_cfg into its own internal-DRAM storage when connect() applies the config. This is unlike the PSRAM-backed MQTT task stack that was tried and reverted for resetting Heltec V4 boards, where the fault was PSRAM execution context rather than a plain buffer read. Split from the previous commit so it can be reverted alone if hardware soak shows any PSRAM-related instability on the JWT path. flash non-PSRAM 1592801 -> 1592865 B (+64) PSRAM 1555213 -> 1555237 B (+24) 267/267 native tests pass; both observer envs and an nRF52 repeater build clean. --- src/helpers/bridges/MQTTBridge.cpp | 12 +++++++++--- src/helpers/bridges/MQTTBridge.h | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 5d42f00f..ebb63887 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1672,12 +1672,18 @@ bool MQTTBridge::ensureSlotClient(int index) { // Allocate this slot's JWT token buffer. Called only from createSlotAuthToken(), the // sole writer, so a slot on a non-JWT preset (or no preset at all) never allocates. -// Internal DRAM, matching where the buffer lived when it was inline in MQTTSlot. +// +// PSRAM where the board has it (psram_malloc falls back to internal DRAM otherwise), +// which is what moves the token off internal heap for slots that DO use JWT. Safe +// because the only readers are CPU copies on the bridge task: JWTHelper memcpy's the +// token in here, and esp-mqtt copies it out of _mqtt_cfg into its own internal-DRAM +// storage when connect() applies the config. No DMA, no ISR, and no cache-disabled +// window -- unlike the PSRAM task stack that reset Heltec V4 boards. bool MQTTBridge::ensureSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; slot.auth_token = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( - slot.auth_token, AUTH_TOKEN_SIZE, malloc)); + slot.auth_token, AUTH_TOKEN_SIZE, psram_malloc)); if (slot.auth_token == nullptr) { MQTT_DEBUG_PRINTLN("MQTT%d: out of memory allocating auth token", index + 1); return false; @@ -1693,7 +1699,7 @@ void MQTTBridge::releaseSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[index]; slot.auth_token = static_cast( - MQTTRuntimeBufferLifecycle::release(slot.auth_token, free)); + MQTTRuntimeBufferLifecycle::release(slot.auth_token, psram_free)); slot.token_expires_at = 0; slot.last_token_renewal = 0; } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index d1c72e3f..4c7436c2 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -88,7 +88,8 @@ private: // JWT auth state (used by preset JWT slots and custom slots with audience set). // nullptr until this slot first creates a token, so a slot that is unconfigured, - // capped off, or on a non-JWT preset never pays for AUTH_TOKEN_SIZE. Allocated by + // capped off, or on a non-JWT preset never pays for AUTH_TOKEN_SIZE; slots that do + // use JWT keep their buffer in PSRAM where the board has it. Allocated by // ensureSlotAuthToken() and then held for the client's lifetime -- never freed per // reconnect (alloc/free churn is a fragmentation source) and never freed on // teardown, because setCredentials() hands this exact pointer to the client and From e242cc6fd303096e6f64ff3e4342f411d5788ff2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 10:13:04 -0700 Subject: [PATCH 06/40] 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. --- src/helpers/MQTTMessageBuilder.cpp | 10 +- src/helpers/MQTTMessageBuilder.h | 16 ++- src/helpers/MQTTPayloadBuilder.cpp | 3 +- src/helpers/MQTTPayloadBuilder.h | 1 + src/helpers/MQTTWireScratch.h | 39 ++++++ src/helpers/bridges/MQTTBridge.cpp | 119 ++++++++++++++---- src/helpers/bridges/MQTTBridge.h | 19 ++- .../test_mqtt_payload_builder.cpp | 6 +- .../test_mqtt_wire_scratch.cpp | 101 +++++++++++++++ 9 files changed, 272 insertions(+), 42 deletions(-) create mode 100644 src/helpers/MQTTWireScratch.h create mode 100644 test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index ebe05120..062f52ed 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -96,6 +96,7 @@ int MQTTMessageBuilder::buildPacketMessage( } int MQTTMessageBuilder::buildRawMessage( + JsonDocument& doc, const char* origin, const char* origin_id, const char* timestamp, @@ -104,7 +105,7 @@ int MQTTMessageBuilder::buildRawMessage( size_t buffer_size ) { return MQTTPayloadBuilder::buildRawMessage( - origin, origin_id, timestamp, raw, buffer, buffer_size); + doc, origin, origin_id, timestamp, raw, buffer, buffer_size); } int MQTTMessageBuilder::buildNeighborsMessage( @@ -297,6 +298,7 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw( } int MQTTMessageBuilder::buildRawJSON( + JsonDocument& doc, mesh::Packet* packet, const char* origin, const char* origin_id, @@ -316,7 +318,7 @@ int MQTTMessageBuilder::buildRawJSON( char raw_hex[WIRE_HEX_SCRATCH_SIZE]; packetToHex(packet, raw_hex, sizeof(raw_hex)); - return buildRawMessage(origin, origin_id, timestamp, raw_hex, buffer, buffer_size); + return buildRawMessage(doc, origin, origin_id, timestamp, raw_hex, buffer, buffer_size); } const char* MQTTMessageBuilder::getRouteTypeString(int route_type) { @@ -355,9 +357,7 @@ void MQTTMessageBuilder::packetToHex(mesh::Packet* packet, char* hex, size_t hex // Serialize full on-air/wire format using Packet::writeTo() // This includes header, transport codes (if present), path_len, path, and payload uint8_t raw_buf[WIRE_SCRATCH_SIZE]; - // writeTo() neither bounds-checks nor can report an overrun (it returns uint8_t), - // so reject an over-long packet on getRawLength() before writing anything. - if (packet->getRawLength() > (int)sizeof(raw_buf)) return; + if (!canSerializePacket(packet, sizeof(raw_buf))) return; uint8_t raw_len = packet->writeTo(raw_buf); if (raw_len == 0) return; diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index 7ef35a1a..91a47b6e 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -3,6 +3,7 @@ #include "MeshCore.h" #include #include "MQTTPayloadBuilder.h" +#include "MQTTWireScratch.h" #include #include @@ -21,13 +22,14 @@ */ class MQTTMessageBuilder { public: - // Wire-format scratch sizing, from the protocol maximum: Packet::writeTo() returns - // uint8_t, so MAX_TRANS_UNIT is the hard ceiling, and hex is 2 chars/byte + NUL. - static const size_t WIRE_SCRATCH_SIZE = MAX_TRANS_UNIT; - static const size_t WIRE_HEX_SCRATCH_SIZE = 2 * MAX_TRANS_UNIT + 1; + // Wire-format scratch sizing and validation live in the pure, host-tested + // MQTTWireScratch; these are the firmware-facing aliases. + static const size_t WIRE_SCRATCH_SIZE = MQTTWireScratch::kWireBytes; + static const size_t WIRE_HEX_SCRATCH_SIZE = MQTTWireScratch::kWireHexChars; - static_assert(1 + 4 + 1 + MAX_PATH_SIZE + MAX_PACKET_PAYLOAD <= MAX_TRANS_UNIT, - "serialized packet no longer fits MAX_TRANS_UNIT — resize the wire scratch buffers"); + static bool canSerializePacket(const mesh::Packet* packet, size_t dest_size) { + return packet != nullptr && MQTTWireScratch::canSerialize(*packet, dest_size); + } /** * Format the MQTT JSON `timestamp` field (same rule for status, packet, raw). @@ -154,6 +156,7 @@ public: * @return Length of JSON string, or 0 on error */ static int buildRawMessage( + JsonDocument& doc, const char* origin, const char* origin_id, const char* timestamp, @@ -240,6 +243,7 @@ public: * @return Length of JSON string, or 0 on error */ static int buildRawJSON( + JsonDocument& doc, mesh::Packet* packet, const char* origin, const char* origin_id, diff --git a/src/helpers/MQTTPayloadBuilder.cpp b/src/helpers/MQTTPayloadBuilder.cpp index f1b49223..265969ae 100644 --- a/src/helpers/MQTTPayloadBuilder.cpp +++ b/src/helpers/MQTTPayloadBuilder.cpp @@ -166,6 +166,7 @@ int MQTTPayloadBuilder::buildPacketMessage( } int MQTTPayloadBuilder::buildRawMessage( + JsonDocument& doc, const char* origin, const char* origin_id, const char* timestamp, @@ -173,7 +174,7 @@ int MQTTPayloadBuilder::buildRawMessage( char* buffer, size_t buffer_size ) { - JsonDocument doc; + doc.clear(); JsonObject root = doc.to(); root["origin"] = origin; diff --git a/src/helpers/MQTTPayloadBuilder.h b/src/helpers/MQTTPayloadBuilder.h index 24e56124..7bca170d 100644 --- a/src/helpers/MQTTPayloadBuilder.h +++ b/src/helpers/MQTTPayloadBuilder.h @@ -61,6 +61,7 @@ public: ); static int buildRawMessage( + JsonDocument& doc, const char* origin, const char* origin_id, const char* timestamp, diff --git a/src/helpers/MQTTWireScratch.h b/src/helpers/MQTTWireScratch.h new file mode 100644 index 00000000..c631cdf1 --- /dev/null +++ b/src/helpers/MQTTWireScratch.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +// Sizing and validation for the scratch buffers that hold a serialized packet. +// Pure so the boundary conditions can be tested on the host: the firmware-side +// callers are MQTTMessageBuilder::packetToHex() and MQTTBridge::publishPacket(). +namespace MQTTWireScratch { + +// A serialized packet is header(1) + transport codes(0|4) + path_len(1) + +// path(<= MAX_PATH_SIZE) + payload(<= MAX_PACKET_PAYLOAD). Packet::writeTo() +// returns uint8_t, so MAX_TRANS_UNIT is the hard ceiling. +static const size_t kWireBytes = MAX_TRANS_UNIT; +// Two uppercase hex chars per byte, plus the NUL. +static const size_t kWireHexChars = 2 * MAX_TRANS_UNIT + 1; + +static_assert(1 + 4 + 1 + MAX_PATH_SIZE + MAX_PACKET_PAYLOAD <= MAX_TRANS_UNIT, + "serialized packet no longer fits MAX_TRANS_UNIT — resize the wire scratch buffers"); + +// True when Packet::writeTo() can safely serialize `packet` into `dest_size` bytes. +// +// writeTo() trusts the packet's own length fields and cannot report an overrun (its +// return type is uint8_t), so the source fields must be checked as well as the +// destination: +// - payload_len drives an unchecked memcpy out of a MAX_PACKET_PAYLOAD array, and a +// corrupt value can still leave getRawLength() inside MAX_TRANS_UNIT. +// - path_len is written into a single wire byte, so anything above 255 is silently +// truncated and would disagree with getPathByteLen(). +// The path bytes themselves need no check here: writePath() already refuses a +// getPathByteLen() above MAX_PATH_SIZE. +inline bool canSerialize(const mesh::Packet& packet, size_t dest_size) { + if (packet.payload_len > MAX_PACKET_PAYLOAD) return false; + if (packet.path_len > 0xFF) return false; + const int raw_len = packet.getRawLength(); + return raw_len > 0 && (size_t)raw_len <= dest_size; +} + +} // namespace MQTTWireScratch diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index ebb63887..6fc26693 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1399,11 +1399,10 @@ void MQTTBridge::mqttTaskLoop() { #endif MQTT_DEBUG_PRINTLN("NTP synced, setting up MQTT slots (max %d active)...", _max_active_slots); - int active_count = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled) { - if (active_count >= _max_active_slots) { - MQTT_DEBUG_PRINTLN("MQTT%d skipped: max active slots (%d) reached (no PSRAM)", i + 1, _max_active_slots); + if (!canActivateSlot(i)) { + MQTT_DEBUG_PRINTLN("MQTT%d skipped: max active slots (%d) reached", i + 1, _max_active_slots); _slots[i].enabled = false; // Disable so other loops skip it continue; } @@ -1412,8 +1411,10 @@ void MQTTBridge::mqttTaskLoop() { MQTT_DEBUG_PRINTLN("MQTT%d not ready - run '%s' to connect", i + 1, reason); continue; } - setupSlot(i); - active_count++; + // A slot that fails to activate consumes no position and stays enabled, so + // maintainSlotConnections() retries it and a later healthy broker is not + // starved by it on a capped board. + if (!setupSlot(i)) continue; // Stagger connections: 5s between slots to avoid simultaneous TLS handshakes // which compete for ~40KB internal heap each if (i < RUNTIME_MQTT_SLOTS - 1) { @@ -1682,13 +1683,18 @@ bool MQTTBridge::ensureSlotClient(int index) { bool MQTTBridge::ensureSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; + const bool fresh = (slot.auth_token == nullptr); slot.auth_token = static_cast(MQTTRuntimeBufferLifecycle::allocateIfMissing( slot.auth_token, AUTH_TOKEN_SIZE, psram_malloc)); if (slot.auth_token == nullptr) { MQTT_DEBUG_PRINTLN("MQTT%d: out of memory allocating auth token", index + 1); return false; } - slot.auth_token[0] = '\0'; + // Initialise only a newly allocated buffer. Clearing on every call would discard a + // valid token at the start of each renewal, so a renewal that then failed inside + // JWTHelper would leave the slot with an empty password where it previously kept + // working credentials (JWTHelper writes the token only on success). + if (fresh) slot.auth_token[0] = '\0'; return true; } @@ -1725,19 +1731,38 @@ void MQTTBridge::destroySlotClients() { } } -void MQTTBridge::setupSlot(int index) { - if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; +int MQTTBridge::activatedSlotCount() const { + int n = 0; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + if (_slots[i].enabled && _slots[i].initial_connect_done) n++; + } + return n; +} + +bool MQTTBridge::canActivateSlot(int index) const { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; + // Already holding a position (a reconfigure of a live slot) — no new position needed. + if (_slots[index].enabled && _slots[index].initial_connect_done) return true; + return activatedSlotCount() < _max_active_slots; +} + +// Returns true only when the slot reached connect(). A false result leaves the slot +// enabled but not activated, so it holds no active-slot position and +// maintainSlotConnections() will retry it — the allocation failures below are transient +// memory conditions, not permanent misconfiguration. +bool MQTTBridge::setupSlot(int index) { + if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; if (!slot.enabled) { teardownSlot(index); - return; + return false; } // First setup for this slot allocates its persistent client; later ones reuse it. if (!ensureSlotClient(index)) { - MQTT_DEBUG_PRINTLN("MQTT%d: client allocation failed - skipping", index + 1); - return; + MQTT_DEBUG_PRINTLN("MQTT%d: client allocation failed - will retry", index + 1); + return false; } // Reconfigure path: if we're re-applying (e.g. after a preset change), stop @@ -1794,12 +1819,15 @@ void MQTTBridge::setupSlot(int index) { slot.client->setCACert(slot.preset->ca_cert); } - // Try to create token and connect (will succeed only if NTP synced) + // A JWT slot with no usable token would connect unauthenticated and be rejected. + // Stay unactivated instead, so the retry path tries again — the failure is either + // a transient token-buffer allocation or a JWTHelper error, not a config problem. if (slot.preset->auth_type == MQTT_AUTH_JWT) { - createSlotAuthToken(index); - if (slot.auth_token && slot.auth_token[0] != '\0') { - slot.client->setCredentials(_jwt_username, slot.auth_token); + if (!createSlotAuthToken(index) || !slot.auth_token || slot.auth_token[0] == '\0') { + MQTT_DEBUG_PRINTLN("MQTT%d: no usable JWT token - will retry", index + 1); + return false; } + slot.client->setCredentials(_jwt_username, slot.auth_token); } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS) { const char* user = nullptr; const char* pass = slot.preset->userpass_password @@ -1908,11 +1936,12 @@ void MQTTBridge::setupSlot(int index) { // Custom slot authentication: JWT if audience is set, else username/password if (slot.audience[0] != '\0') { - // JWT auth for custom slot — create initial token (allocates the buffer) - createSlotAuthToken(index); - if (slot.auth_token && slot.auth_token[0] != '\0') { - slot.client->setCredentials(_jwt_username, slot.auth_token); + // JWT auth for custom slot — same rule as the preset JWT path above. + if (!createSlotAuthToken(index) || !slot.auth_token || slot.auth_token[0] == '\0') { + MQTT_DEBUG_PRINTLN("MQTT%d: no usable JWT token - will retry", index + 1); + return false; } + slot.client->setCredentials(_jwt_username, slot.auth_token); MQTT_DEBUG_PRINTLN("MQTT%d custom broker using JWT auth (audience: %s)", index + 1, slot.audience); } else if (strlen(slot.username) > 0) { slot.client->setCredentials(slot.username, slot.password); @@ -1921,6 +1950,7 @@ void MQTTBridge::setupSlot(int index) { slot.client->connect(); slot.initial_connect_done = true; + return true; } // Disconnect the slot's MQTT client and clear per-connection state, but leave @@ -1988,8 +2018,12 @@ void MQTTBridge::maintainSlotConnections() { // when multiple slots fail simultaneously bool teardown_attempted_this_cycle = false; + // At most one deferred setup retry per cycle: a successful one ends in connect(), so + // this shares the "no simultaneous TLS handshakes" rule the reconnect guard enforces. + bool setup_retry_this_cycle = false; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { - if (!_slots[i].enabled || !_slots[i].client) continue; + if (!_slots[i].enabled) continue; // JWT slots need time sync before we can manage tokens bool slot_jwt = (_slots[i].preset && _slots[i].preset->auth_type == MQTT_AUTH_JWT) || @@ -1998,6 +2032,27 @@ void MQTTBridge::maintainSlotConnections() { continue; } + // Enabled but never activated: setupSlot() failed on a client or token allocation, + // or on token creation. The ladder below is gated on initial_connect_done and would + // never revisit it, and maintenance used to skip clientless slots entirely, so + // without this the slot stayed dead until a reconfigure or reboot. Only retried + // after the initial pass has run, so the NTP-deferred setup order is preserved. + if (!_slots[i].initial_connect_done) { + if (_slots_setup_done && !setup_retry_this_cycle && !reconnect_attempted_this_cycle && + isSlotReady(i) && canActivateSlot(i) && + MQTTConnectionPolicy::elapsedMs(static_cast(now_millis), + static_cast(_slots[i].last_reconnect_attempt)) + >= SLOT_SETUP_RETRY_INTERVAL) { + _slots[i].last_reconnect_attempt = now_millis; + setup_retry_this_cycle = true; + MQTT_DEBUG_PRINTLN("MQTT%d retrying deferred setup (int_heap=%d)", i + 1, + (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); + if (setupSlot(i)) _last_slot_reconnect_ms = now_millis; + } + continue; + } + if (!_slots[i].client) continue; + maintainSlotConnection(i, now_millis, current_time, time_synced, reconnect_attempted_this_cycle, teardown_attempted_this_cycle); } } @@ -2515,6 +2570,13 @@ void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) { slot.audience[sizeof(slot.audience) - 1] = '\0'; slot.enabled = (slot.host[0] != '\0'); if (_initialized && slot.enabled && customEndpointComplete(slot.host, slot.port)) { + // Same cap startup applies. teardownSlot() above already released this slot's own + // position, so reconfiguring a live slot still passes. + if (!canActivateSlot(slot_index)) { + MQTT_DEBUG_PRINTLN("MQTT%d skipped: max active slots (%d) reached", slot_index + 1, _max_active_slots); + slot.enabled = false; + return; + } setupSlot(slot_index); } return; @@ -2536,6 +2598,13 @@ void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) { MQTT_DEBUG_PRINTLN("MQTT%d (%s) not ready - run '%s' to connect", slot_index + 1, preset_name, reason); return; } + // Same cap startup applies. Without this a live reconfigure could raise a + // non-PSRAM board to three concurrent TLS sessions against a cap of two. + if (!canActivateSlot(slot_index)) { + MQTT_DEBUG_PRINTLN("MQTT%d skipped: max active slots (%d) reached", slot_index + 1, _max_active_slots); + slot.enabled = false; + return; + } setupSlot(slot_index); } } @@ -2722,10 +2791,9 @@ void MQTTBridge::loop() { // Deferred slot setup after NTP sync (non-ESP32 path) if (_ntp_synced && !_slots_setup_done) { _slots_setup_done = true; - int active_count = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled) { - if (active_count >= _max_active_slots) { + if (!canActivateSlot(i)) { _slots[i].enabled = false; continue; } @@ -2733,7 +2801,6 @@ void MQTTBridge::loop() { continue; } setupSlot(i); - active_count++; } } } @@ -3423,10 +3490,11 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, } else { // Reconstruct wire-format bytes from packet (same as MQTTMessageBuilder::packetToHex). // Reached when the queued item carried no captured raw frame, so the "raw" hex field - // is re-serialized rather than dropped. Length-guarded for the same reason. + // is re-serialized rather than dropped. Guarded on the packet's own length fields + // as well as the destination, for the reasons in canSerializePacket(). uint8_t reconstructed[MQTTMessageBuilder::WIRE_SCRATCH_SIZE]; uint8_t rlen = 0; - if (packet->getRawLength() <= (int)sizeof(reconstructed)) { + if (MQTTMessageBuilder::canSerializePacket(packet, sizeof(reconstructed))) { rlen = packet->writeTo(reconstructed); } if (rlen > 0) { @@ -3503,6 +3571,7 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) { origin_id[sizeof(origin_id) - 1] = '\0'; int len = MQTTMessageBuilder::buildRawJSON( + _json_scratch_doc, packet, _origin, origin_id, _timezone, active_buffer, active_buffer_size ); diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 4c7436c2..eb7ab943 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -329,8 +329,11 @@ private: // Routes the shared document's pools to PSRAM where the board has it, matching the // neighbors document's allocator in MyMesh.cpp. ArduinoJson's default allocator is - // plain malloc(), which puts every per-publish pool block (4096 bytes here) in - // internal DRAM next to the mbedTLS working set. + // plain malloc(), which puts every per-publish pool block in internal DRAM next to + // the mbedTLS working set. A block is ARDUINOJSON_POOL_CAPACITY slots: these targets + // are 32-bit, so ARDUINOJSON_SLOT_ID_SIZE is 2 and that resolves to 128 slots = + // 1024 bytes per block, not the 4096 quoted near NEIGHBORS_DOC_POOL_BUDGET below + // (which describes a 64-bit configuration; its own byte measurements still stand). struct JsonScratchAllocator : ArduinoJson::Allocator { void* allocate(size_t size) override; void deallocate(void* ptr) override; @@ -378,6 +381,10 @@ private: unsigned long _last_no_broker_log; static const unsigned long NO_BROKER_LOG_INTERVAL = 30000; // Log every 30 seconds max static const unsigned long SLOT_LOG_INTERVAL = 30000; // Log every 30 seconds max + // Retry cadence for a slot whose setup failed on an allocation. Deliberately slower + // than the first backoff rung: the failure means internal heap is exhausted, and a + // retry that succeeds immediately launches a TLS handshake. + static const unsigned long SLOT_SETUP_RETRY_INTERVAL = 60000; unsigned long _last_config_warning; // Throttle configuration mismatch warnings static const unsigned long CONFIG_WARNING_INTERVAL = 300000; // Log every 5 minutes max @@ -419,7 +426,13 @@ private: bool ensureSlotAuthToken(int index); // Allocate this slot's JWT token buffer on first token creation void releaseSlotAuthToken(int index);// Free the token buffer (only with the client — see MQTTSlot) void destroySlotClients(); // Delete all persistent clients (shutdown only) - void setupSlot(int index); // Configure and connect the slot's existing client + bool setupSlot(int index); // Configure and connect the slot; false = not activated + // Single definition of "this slot holds one of the _max_active_slots positions": + // it is enabled and has been through a successful setupSlot(). Startup, the + // setup-retry path, and live reconfigure all gate on these so the cap cannot be + // exceeded by one route while another enforces it. + int activatedSlotCount() const; + bool canActivateSlot(int index) const; void teardownSlot(int index); // Disconnect the slot's client (keeps the object alive) void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect) void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted); 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 a3b0866f..37486fd2 100644 --- a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp +++ b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp @@ -171,8 +171,9 @@ TEST(MQTTPayloadBuilder, RxPacketOmitsUnknownNanScore) { TEST(MQTTPayloadBuilder, RawMessageHasExactContractAndEscapesData) { char buffer[512]; + JsonDocument doc; int len = MQTTPayloadBuilder::buildRawMessage( - "node \"A\"", "id\\1", kTimestamp, "AA\nBB", buffer, sizeof(buffer)); + doc, "node \"A\"", "id\\1", kTimestamp, "AA\nBB", buffer, sizeof(buffer)); ASSERT_GT(len, 0); EXPECT_EQ(static_cast(len), strlen(buffer)); @@ -224,8 +225,9 @@ TEST(MQTTPayloadBuilder, MaximumRepresentativePacketAndRawPayloadsRemainValid) { EXPECT_STREQ("3c3d3e3f", parsed_path[15].as()); char raw_buffer[1024]; + JsonDocument raw_doc; int raw_len = MQTTPayloadBuilder::buildRawMessage( - "node", "0123456789ABCDEF", kTimestamp, raw.c_str(), + raw_doc, "node", "0123456789ABCDEF", kTimestamp, raw.c_str(), raw_buffer, sizeof(raw_buffer)); ASSERT_GT(raw_len, 0); JsonDocument parsed_raw; diff --git a/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp b/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp new file mode 100644 index 00000000..b78e23bd --- /dev/null +++ b/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp @@ -0,0 +1,101 @@ +// Boundary tests for the wire-format scratch sizing used by the MQTT raw/packet +// publish paths. Packet::writeTo() cannot report an overrun (uint8_t return) and +// trusts the packet's own length fields, so canSerialize() is what keeps it in +// bounds — these cases pin the exact accept/reject edges. +#include + +#include "helpers/MQTTWireScratch.h" + +namespace { + +// A packet that serializes to the largest legal wire form: transport codes present, +// a full path, and a full payload. +mesh::Packet maxPacket() { + mesh::Packet p; + p.header = ROUTE_TYPE_TRANSPORT_DIRECT | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT); + p.transport_codes[0] = 0x1234; + p.transport_codes[1] = 0x5678; + // The hop count field is 6 bits, so MAX_PATH_SIZE one-byte hops is NOT encodable + // (64 & 63 == 0). 32 hops of 2 bytes is the widest path that reaches MAX_PATH_SIZE. + p.setPathHashSizeAndCount(2, 32); + EXPECT_EQ(MAX_PATH_SIZE, p.getPathByteLen()); + p.payload_len = MAX_PACKET_PAYLOAD; + memset(p.payload, 0xAB, sizeof(p.payload)); + return p; +} + +} // namespace + +TEST(MQTTWireScratch, MaxLegalPacketFitsTheScratchBuffer) { + mesh::Packet p = maxPacket(); + // 1 header + 4 transport + 1 path_len + 64 path + 184 payload = 254. + EXPECT_EQ(254, p.getRawLength()); + EXPECT_TRUE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); + + uint8_t buf[MQTTWireScratch::kWireBytes]; + const uint8_t written = p.writeTo(buf); + EXPECT_EQ(254, (int)written); + EXPECT_LE((size_t)written, sizeof(buf)); + // The hex buffer must hold two chars per byte plus the NUL. + EXPECT_GE(MQTTWireScratch::kWireHexChars, (size_t)written * 2 + 1); +} + +TEST(MQTTWireScratch, RejectsPayloadLenPastTheArrayEvenWhenEncodedLengthFits) { + mesh::Packet p; + p.header = ROUTE_TYPE_FLOOD; + p.setPathHashSizeAndCount(1, 0); + // getRawLength() == 2 + 0 + 185 == 187, comfortably inside MAX_TRANS_UNIT, but + // writeTo() would memcpy 185 bytes out of a 184-byte array. + p.payload_len = MAX_PACKET_PAYLOAD + 1; + EXPECT_LE(p.getRawLength(), (int)MQTTWireScratch::kWireBytes); + EXPECT_FALSE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); + + p.payload_len = MAX_PACKET_PAYLOAD; + EXPECT_TRUE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); +} + +TEST(MQTTWireScratch, RejectsPathLenThatWouldTruncateIntoOneWireByte) { + mesh::Packet p; + p.header = ROUTE_TYPE_FLOOD; + p.payload_len = 4; + p.path_len = 0x100; // writeTo() stores this in a single byte + EXPECT_FALSE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); + + p.path_len = 0xFF; + // Still rejected, but now on the destination check rather than truncation: + // 0xFF encodes 63 hops of 4 bytes. + EXPECT_GT(p.getRawLength(), (int)MQTTWireScratch::kWireBytes); + EXPECT_FALSE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); +} + +TEST(MQTTWireScratch, DestinationEdgeIsInclusive) { + mesh::Packet p = maxPacket(); + const size_t exact = (size_t)p.getRawLength(); + EXPECT_TRUE(MQTTWireScratch::canSerialize(p, exact)); + EXPECT_FALSE(MQTTWireScratch::canSerialize(p, exact - 1)); +} + +// A zero-payload packet serializes fine but does not survive readFrom(), which +// requires at least one payload byte. canSerialize() deliberately does not reject it: +// the raw/packet publish paths only ever write the bytes out. Any future change that +// reconstructs a Packet from queued wire bytes has to handle this asymmetry. +TEST(MQTTWireScratch, ZeroPayloadPacketSerializesButDoesNotRoundTrip) { + mesh::Packet p; + p.header = ROUTE_TYPE_FLOOD; + p.setPathHashSizeAndCount(1, 0); + p.payload_len = 0; + EXPECT_EQ(2, p.getRawLength()); + EXPECT_TRUE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); + + uint8_t buf[MQTTWireScratch::kWireBytes]; + const uint8_t written = p.writeTo(buf); + EXPECT_EQ(2, (int)written); + + mesh::Packet restored; + EXPECT_FALSE(restored.readFrom(buf, written)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 6be84eb4eb4bb23acb4770b916499c5cc5bd6856 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 14:03:21 -0700 Subject: [PATCH 07/40] fix(mqtt): let a retried setup consume the reconnect allowance reconnect_attempted_this_cycle is computed once before the maintenance loop and only maintainSlotConnection() was setting it. The deferred-setup retry armed the 15 s cross-slot guard via _last_slot_reconnect_ms but left the local flag false, so a disconnected slot later in the same pass still saw "no reconnect yet" and started a second TLS handshake. Two concurrent ~40 KB sessions is exactly the contention the guard prevents, and it landed in the one situation where internal heap is already known to be short -- a failed allocation is why the retry runs. Set on success only: setupSlot() returns true only after client->connect(), so success means a handshake was launched. A failed retry launches nothing and continues to spend only setup_retry_this_cycle, which rate-limits the allocation attempts without consuming the handshake allowance. --- src/helpers/bridges/MQTTBridge.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 6fc26693..90ffbf71 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -2047,7 +2047,17 @@ void MQTTBridge::maintainSlotConnections() { setup_retry_this_cycle = true; MQTT_DEBUG_PRINTLN("MQTT%d retrying deferred setup (int_heap=%d)", i + 1, (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); - if (setupSlot(i)) _last_slot_reconnect_ms = now_millis; + if (setupSlot(i)) { + // A successful setup ends in connect(), so it spends this cycle's single + // handshake allowance as well as arming the 15 s cross-slot guard. Without + // the local flag, a disconnected slot later in this same pass would start a + // second concurrent TLS handshake — the contention the guard exists to + // prevent, and most damaging here because a failed allocation is why we are + // retrying at all. A failed setup launches nothing and so spends only + // setup_retry_this_cycle. + _last_slot_reconnect_ms = now_millis; + reconnect_attempted_this_cycle = true; + } } continue; } From 53bae5684e79ad425e4e05bcf7d4fd18b16437ac Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 14:04:08 -0700 Subject: [PATCH 08/40] fix(mqtt): measure the setup-retry interval from the failure last_reconnect_attempt starts at zero and teardownSlot() re-zeroes it, so the retry gate in maintainSlotConnections() reduced to "uptime >= SLOT_SETUP_RETRY_INTERVAL". Past 60 s of uptime a failed setup was therefore retried on the next maintenance pass rather than 60 s later -- in the same task iteration for a live reconfigure, since reconfigure processing runs before maintenance in the loop. setupSlot() now stamps last_reconnect_attempt on each failure that represents a real attempt (client allocation, and both JWT token paths), so all three callers get the interval measured from the failure. The bounds check and the !enabled early return are not attempts and stay unstamped. The reconnect ladder reads this field only for slots with initial_connect_done set, which a failed setup never sets, so reconnect timing is unaffected. The retry path's own pre-call stamp is kept as a backstop for any future false-returning path that does not stamp itself. --- src/helpers/bridges/MQTTBridge.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 90ffbf71..89373341 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1759,9 +1759,18 @@ bool MQTTBridge::setupSlot(int index) { return false; } + // Every failure below is a real attempt, so stamp it: the retry interval in + // maintainSlotConnections() measures from last_reconnect_attempt, which starts at 0 + // and is re-zeroed by teardownSlot(). Left unstamped, the gate degenerates to + // "uptime >= SLOT_SETUP_RETRY_INTERVAL" and a failure past that point is retried on + // the very next maintenance pass — the same task iteration, for a live reconfigure. + // The reconnect ladder never reads this field for an unactivated slot (it is gated + // on initial_connect_done), so stamping here cannot perturb reconnect timing. + // First setup for this slot allocates its persistent client; later ones reuse it. if (!ensureSlotClient(index)) { MQTT_DEBUG_PRINTLN("MQTT%d: client allocation failed - will retry", index + 1); + slot.last_reconnect_attempt = millis(); return false; } @@ -1825,6 +1834,7 @@ bool MQTTBridge::setupSlot(int index) { if (slot.preset->auth_type == MQTT_AUTH_JWT) { if (!createSlotAuthToken(index) || !slot.auth_token || slot.auth_token[0] == '\0') { MQTT_DEBUG_PRINTLN("MQTT%d: no usable JWT token - will retry", index + 1); + slot.last_reconnect_attempt = millis(); return false; } slot.client->setCredentials(_jwt_username, slot.auth_token); @@ -1939,6 +1949,7 @@ bool MQTTBridge::setupSlot(int index) { // JWT auth for custom slot — same rule as the preset JWT path above. if (!createSlotAuthToken(index) || !slot.auth_token || slot.auth_token[0] == '\0') { MQTT_DEBUG_PRINTLN("MQTT%d: no usable JWT token - will retry", index + 1); + slot.last_reconnect_attempt = millis(); return false; } slot.client->setCredentials(_jwt_username, slot.auth_token); From dbdf2d732f298ca97d8bdb131e879a122f31126c Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 14:04:39 -0700 Subject: [PATCH 09/40] fix(mqtt): scan every slot for a stale JWT after a clock correction The post-NTP-correction refresh looped to _max_active_slots, which is a count of activation positions and never an index bound. The indices holding those positions are not contiguous: a slot passed over by isSlotReady() -- or, since the demand-driven work, by a failed setup -- leaves a higher index activated. On a two-position board that meant slots 1 and 2 could be live while only indices 0 and 1 were scanned, so slot 3 kept a JWT issued against the pre-correction clock until its own expiry or a reconnect regenerated it. Now bounded by RUNTIME_MQTT_SLOTS. The existing guard already skips disabled, non-JWT, and clientless slots, so widening the range cannot touch a slot that was never set up. Pre-existing (the loop predates the demand-driven work) and kept as its own commit so it can be picked separately. Audited the other _max_active_slots uses: all are count comparisons or log arguments, so this was the only misuse. --- src/helpers/bridges/MQTTBridge.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 89373341..735999e9 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -3925,7 +3925,13 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { // and re-setup all JWT-authenticated slots so they get fresh tokens. if (_slots_setup_done && was_ntp_synced) { unsigned long current_time = (unsigned long)time(nullptr); - for (int i = 0; i < _max_active_slots; i++) { + // Every slot, not _max_active_slots: that is a count of positions, never an + // index bound. Which indices hold those positions is not contiguous — a slot can + // fail isSlotReady() or its setup and be passed over, leaving a higher index + // activated — so bounding by the cap silently skipped an activated slot and left + // it holding a JWT issued against the pre-correction clock. The guard below + // already excludes disabled, non-JWT, and clientless slots. + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { bool slot_jwt = (_slots[i].preset && _slots[i].preset->auth_type == MQTT_AUTH_JWT) || (!_slots[i].preset && _slots[i].audience[0] != '\0'); if (_slots[i].enabled && slot_jwt && _slots[i].client) { From 8d1a0eb3334a2120d362d0ffa8be77c8ad29b64d Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 4 Aug 2026 14:06:32 -0700 Subject: [PATCH 10/40] fix(mqtt): reject invalid path encodings before serializing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canSerialize() validated payload_len and the destination size but not whether the path encoding is one writePath() will actually emit. writePath() self-guards against overrunning the path array, but it does so by writing nothing and returning 0 — a correctness problem, not a safety one, because getRawLength() still counts the path. An over-long or reserved encoding therefore passed the size check and then serialized to a truncated frame that was published as the packet. Worst case is path_len 0xFF with no payload: 63 hops of 4 bytes counts as 254 bytes, inside the 255-byte buffer, while writeTo() emits just the 2-byte header. The `raw` field would carry 4 hex chars presented as the frame. Reserved 4-byte hash encodings passed too, producing frames Packet::readFrom() rejects. Now gated on Packet::isValidPathLen(), which rejects the reserved 4-byte hash size and any count * size above MAX_PATH_SIZE in one predicate. It is the same check readFrom() applies to every received packet, and TX packets are built via setPathHashSizeAndCount() with real hash sizes, so no decodable packet is turned away. Two tests added for the cases a destination-size check cannot reach. The existing truncation test passed for the wrong reason -- its payload_len of 4 pushed getRawLength() to 258 and tripped the size check, masking the hole -- so it is split into the >0xFF truncation case and the counted-length-fits case, with the 254/2-byte asymmetry asserted explicitly so it cannot be masked again. 274/274 native tests; both observer envs and an nRF52 repeater build clean. --- src/helpers/MQTTWireScratch.h | 12 +++++- .../test_mqtt_wire_scratch.cpp | 37 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/helpers/MQTTWireScratch.h b/src/helpers/MQTTWireScratch.h index c631cdf1..d5c79336 100644 --- a/src/helpers/MQTTWireScratch.h +++ b/src/helpers/MQTTWireScratch.h @@ -27,11 +27,19 @@ static_assert(1 + 4 + 1 + MAX_PATH_SIZE + MAX_PACKET_PAYLOAD <= MAX_TRANS_UNIT, // corrupt value can still leave getRawLength() inside MAX_TRANS_UNIT. // - path_len is written into a single wire byte, so anything above 255 is silently // truncated and would disagree with getPathByteLen(). -// The path bytes themselves need no check here: writePath() already refuses a -// getPathByteLen() above MAX_PATH_SIZE. +// - the path encoding must be one writePath() will actually emit. It self-guards +// against overrunning the path array, but by writing nothing and returning 0, which +// is a correctness problem rather than a safety one: getRawLength() still counts the +// path, so an over-long or reserved encoding passes a destination-size check and +// then serializes to a truncated frame that gets published as the packet. The worst +// case is path_len 0xFF with no payload — 254 counted bytes, 2 bytes emitted. +// isValidPathLen() rejects both the reserved 4-byte hash size and any +// count * size above MAX_PATH_SIZE, and is the same predicate Packet::readFrom() +// applies to every received packet, so no decodable packet is turned away. inline bool canSerialize(const mesh::Packet& packet, size_t dest_size) { if (packet.payload_len > MAX_PACKET_PAYLOAD) return false; if (packet.path_len > 0xFF) return false; + if (!mesh::Packet::isValidPathLen((uint8_t)packet.path_len)) return false; const int raw_len = packet.getRawLength(); return raw_len > 0 && (size_t)raw_len <= dest_size; } diff --git a/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp b/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp index b78e23bd..d8258aea 100644 --- a/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp +++ b/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp @@ -60,11 +60,42 @@ TEST(MQTTWireScratch, RejectsPathLenThatWouldTruncateIntoOneWireByte) { p.payload_len = 4; p.path_len = 0x100; // writeTo() stores this in a single byte EXPECT_FALSE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); +} +// The case a destination-size check cannot catch, and which an earlier version of +// these tests masked by using a payload big enough to push getRawLength() over the +// limit: 0xFF encodes 63 hops of 4 bytes, so with no payload the counted length is +// 254 — inside the buffer — while writePath() refuses the 252-byte path and writeTo() +// emits only the 2-byte header. Publishing that would put 4 hex chars in the `raw` +// field and call them the packet. +TEST(MQTTWireScratch, RejectsOverlongPathEvenWhenTheCountedLengthFits) { + mesh::Packet p; + p.header = ROUTE_TYPE_FLOOD; p.path_len = 0xFF; - // Still rejected, but now on the destination check rather than truncation: - // 0xFF encodes 63 hops of 4 bytes. - EXPECT_GT(p.getRawLength(), (int)MQTTWireScratch::kWireBytes); + p.payload_len = 0; + + ASSERT_EQ(254, p.getRawLength()); + ASSERT_LE((size_t)p.getRawLength(), MQTTWireScratch::kWireBytes); + uint8_t buf[MQTTWireScratch::kWireBytes]; + ASSERT_EQ(2, (int)p.writeTo(buf)); + + EXPECT_FALSE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); +} + +// hash_size 4 is reserved: isValidPathLen() and therefore Packet::readFrom() reject +// it, so serializing one produces a frame no receiver can parse back — even though the +// hop bytes fit and writePath() copies them happily. +TEST(MQTTWireScratch, RejectsReservedFourByteHashEncoding) { + mesh::Packet p; + p.header = ROUTE_TYPE_FLOOD; + p.setPathHashSizeAndCount(4, 2); + p.payload_len = 4; + + ASSERT_EQ(4, p.getPathHashSize()); + ASSERT_EQ(8, p.getPathByteLen()); // fits the path array + ASSERT_LE(p.getRawLength(), (int)MQTTWireScratch::kWireBytes); + ASSERT_FALSE(mesh::Packet::isValidPathLen((uint8_t)p.path_len)); + EXPECT_FALSE(MQTTWireScratch::canSerialize(p, MQTTWireScratch::kWireBytes)); } From 23066573e56528a6be6bf2aef27cc23edad8446e Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 6 Aug 2026 15:29:14 -0700 Subject: [PATCH 11/40] fix(station-g3): expose FEM gain preferences --- docs/cli_commands.md | 15 +++++ examples/simple_repeater/MyMesh.cpp | 2 + examples/simple_room_server/MyMesh.cpp | 2 + examples/simple_sensor/SensorMesh.cpp | 2 + src/MeshCore.h | 4 ++ src/helpers/CommonCLI.cpp | 32 ++++++++- src/helpers/CommonCLI.h | 4 +- variants/station_g3_esp32/LoRaFEMControl.cpp | 69 ++++++++++++++++++++ variants/station_g3_esp32/LoRaFEMControl.h | 20 ++++++ variants/station_g3_esp32/StationG3Board.cpp | 35 +++++++++- variants/station_g3_esp32/StationG3Board.h | 53 +++++---------- variants/station_g3_esp32/platformio.ini | 4 +- 12 files changed, 198 insertions(+), 44 deletions(-) create mode 100644 variants/station_g3_esp32/LoRaFEMControl.cpp create mode 100644 variants/station_g3_esp32/LoRaFEMControl.h diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b618ae2b..390c8e04 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -291,6 +291,21 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change the LoRa FEM transmit-path gain state on supported boards +**Usage:** +- `get radio.fem.txgain` +- `set radio.fem.txgain ` + +**Parameters:** +- `state`: `on`|`off` + +**Notes:** +- This controls a software-selectable external LoRa FEM transmit gain where the board supports it. +- On Station G3, remove the PA PL1 jumper to allow software control. `on` selects PA PL1 high/short and `off` selects PA PL1 low/open. The PA PL2 hardware jumper determines whether this switches between power levels 1/3 or 2/4. +- Select an operating level and SX1262 transmit power that comply with local RF limits and the Station G3 power-supply requirements. + +--- + ### System #### View or change this node's name diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c93ba1a4..d3c2e160 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -913,6 +913,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif #endif _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_txgain = 0; pending_discover_tag = 0; pending_discover_until = 0; @@ -962,6 +963,7 @@ void MyMesh::begin(FILESYSTEM *fs) { MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 0aff39cc..f7ec1480 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -683,6 +683,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif #endif _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_txgain = 0; next_post_idx = 0; next_client_idx = 0; @@ -726,6 +727,7 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 17f8e323..9bfa5ec6 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -735,6 +735,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_txgain = 0; memset(default_scope.key, 0, sizeof(default_scope.key)); } @@ -771,6 +772,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/src/MeshCore.h b/src/MeshCore.h index 89e60b1f..e67371ef 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -67,6 +67,10 @@ public: virtual bool setLoRaFemLnaEnabled(bool enable) { return false; } virtual bool canControlLoRaFemLna() const { return false; } virtual bool isLoRaFemLnaEnabled() const { return false; } + // Software-selectable external FEM transmit gain. This is not a PA power switch. + virtual bool setLoRaFemPaGainEnabled(bool enable) { return false; } + virtual bool canControlLoRaFemPaGain() const { return false; } + virtual bool isLoRaFemPaGainEnabled() const { return false; } // Power management interface (boards with power management override these) virtual bool isExternalPowered() { return false; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 07181e16..56a52a0b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -102,7 +102,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - // next: 295 + file.read((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 295 + // next: 296 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -133,6 +134,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean + _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean file.close(); @@ -562,6 +564,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } else { strcpy(reply, "Error: state must be on or off"); } + } else if (memcmp(config, "radio.fem.txgain ", 17) == 0) { + if (!_board->canControlLoRaFemPaGain()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&config[17], "on", 2) == 0) { + if (_board->setLoRaFemPaGainEnabled(true)) { + _prefs->radio_fem_txgain = 1; + savePrefs(); + strcpy(reply, "OK - LoRa FEM TX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); + } + } else if (memcmp(&config[17], "off", 3) == 0) { + if (_board->setLoRaFemPaGainEnabled(false)) { + _prefs->radio_fem_txgain = 0; + savePrefs(); + strcpy(reply, "OK - LoRa FEM TX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } } else if (memcmp(config, "radio ", 6) == 0) { strcpy(tmp, &config[6]); const char *parts[4]; @@ -827,6 +851,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "> %s", _board->isLoRaFemLnaEnabled() ? "on" : "off"); } + } else if (memcmp(config, "radio.fem.txgain", 16) == 0) { + if (!_board->canControlLoRaFemPaGain()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", _board->isLoRaFemPaGainEnabled() ? "on" : "off"); + } } else if (memcmp(config, "radio", 5) == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2a9ec43b..237c758e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -65,6 +65,7 @@ public: char owner_info[120]; uint8_t rx_boosted_gain = 0; // power settings uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting + uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) @@ -82,7 +83,8 @@ private: def("cad", _parent->cad_enabled); def("int_thr", _parent->interference_threshold); def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->rx_boosted_gain); + def("fem_rxgain", _parent->radio_fem_rxgain); + def("fem_txgain", _parent->radio_fem_txgain); def("tx", _parent->tx_power_dbm); def("af", _parent->airtime_factor); def("rxdelay", _parent->rx_delay_base); diff --git a/variants/station_g3_esp32/LoRaFEMControl.cpp b/variants/station_g3_esp32/LoRaFEMControl.cpp new file mode 100644 index 00000000..04ac2ff8 --- /dev/null +++ b/variants/station_g3_esp32/LoRaFEMControl.cpp @@ -0,0 +1,69 @@ +#include "LoRaFEMControl.h" + +#include +#include + +void LoRaFEMControl::init() { +#ifdef P_PA1_EN + rtc_gpio_hold_dis((gpio_num_t)P_PA1_EN); + pinMode(P_PA1_EN, OUTPUT); + setPAGainEnable(pa_gain_enabled); +#endif + +#ifdef P_PRIMARY_LNA_EN + rtc_gpio_hold_dis((gpio_num_t)P_PRIMARY_LNA_EN); + pinMode(P_PRIMARY_LNA_EN, OUTPUT); + setRxModeEnable(); +#endif +} + +void LoRaFEMControl::setSleepModeEnable() { +#ifdef P_PA1_EN + // PA PL1 low/open selects the lower of the two hardware-jumper-selected levels. + digitalWrite(P_PA1_EN, !P_PA1_EN_ACTIVE); +#endif +#ifdef P_PRIMARY_LNA_EN + // Preserve the existing Station G3 power-off state. + digitalWrite(P_PRIMARY_LNA_EN, P_PRIMARY_LNA_EN_ACTIVE); +#endif +} + +void LoRaFEMControl::setTxModeEnable() { +#ifdef P_PRIMARY_LNA_EN + digitalWrite(P_PRIMARY_LNA_EN, !P_PRIMARY_LNA_EN_ACTIVE); +#endif +} + +void LoRaFEMControl::setRxModeEnable() { +#ifdef P_PRIMARY_LNA_EN + digitalWrite(P_PRIMARY_LNA_EN, lna_enabled ? P_PRIMARY_LNA_EN_ACTIVE : !P_PRIMARY_LNA_EN_ACTIVE); +#endif +} + +void LoRaFEMControl::setLNAEnable(bool enabled) { + lna_enabled = enabled; + setRxModeEnable(); +} + +void LoRaFEMControl::setPAGainEnable(bool enabled) { + pa_gain_enabled = enabled; +#ifdef P_PA1_EN + digitalWrite(P_PA1_EN, enabled ? P_PA1_EN_ACTIVE : !P_PA1_EN_ACTIVE); +#endif +} + +bool LoRaFEMControl::canControlLNA() const { +#ifdef P_PRIMARY_LNA_EN + return true; +#else + return false; +#endif +} + +bool LoRaFEMControl::canControlPAGain() const { +#ifdef P_PA1_EN + return true; +#else + return false; +#endif +} diff --git a/variants/station_g3_esp32/LoRaFEMControl.h b/variants/station_g3_esp32/LoRaFEMControl.h new file mode 100644 index 00000000..429d6127 --- /dev/null +++ b/variants/station_g3_esp32/LoRaFEMControl.h @@ -0,0 +1,20 @@ +#pragma once + +class LoRaFEMControl { +public: + void init(); + void setSleepModeEnable(); + void setTxModeEnable(); + void setRxModeEnable(); + void setLNAEnable(bool enabled); + void setPAGainEnable(bool enabled); + + bool canControlLNA() const; + bool canControlPAGain() const; + bool isLNAEnabled() const { return lna_enabled; } + bool isPAGainEnabled() const { return pa_gain_enabled; } + +private: + bool lna_enabled = true; + bool pa_gain_enabled = false; +}; diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp index 4a498311..dd863aca 100644 --- a/variants/station_g3_esp32/StationG3Board.cpp +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -1,15 +1,46 @@ #include "StationG3Board.h" void StationG3Board::powerOff() { + loRaFEMControl.setSleepModeEnable(); #ifdef P_PA1_EN - setPAModeHigh(false); rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); #endif #ifdef P_PRIMARY_LNA_EN - setPrimaryLNAControl(true); rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); #endif ESP32Board::powerOff(); } + +bool StationG3Board::setLoRaFemLnaEnabled(bool enable) { + if (!loRaFEMControl.canControlLNA()) { + return false; + } + loRaFEMControl.setLNAEnable(enable); + return true; +} + +bool StationG3Board::canControlLoRaFemLna() const { + return loRaFEMControl.canControlLNA(); +} + +bool StationG3Board::isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); +} + +bool StationG3Board::setLoRaFemPaGainEnabled(bool enable) { + if (!loRaFEMControl.canControlPAGain()) { + return false; + } + loRaFEMControl.setPAGainEnable(enable); + return true; +} + +bool StationG3Board::canControlLoRaFemPaGain() const { + return loRaFEMControl.canControlPAGain(); +} + +bool StationG3Board::isLoRaFemPaGainEnabled() const { + return loRaFEMControl.isPAGainEnabled(); +} diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index 4b1fb81c..52628eb6 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -3,45 +3,15 @@ #include #include #include - -#ifndef P_PRIMARY_LNA_EN_ACTIVE -#define P_PRIMARY_LNA_EN_ACTIVE LOW -#endif - -#ifndef P_PA1_EN_ACTIVE -#define P_PA1_EN_ACTIVE HIGH -#endif +#include "LoRaFEMControl.h" class StationG3Board : public ESP32Board { - void setPAModeHigh(bool enabled) { -#ifdef P_PA1_EN - // Station G3 PA PL1 mode: LOW/open is PA low, HIGH/short is PA high. - digitalWrite(P_PA1_EN, enabled ? P_PA1_EN_ACTIVE : !P_PA1_EN_ACTIVE); -#endif - } - - void setPrimaryLNAControl(bool enabled) { -#ifdef P_PRIMARY_LNA_EN - // Station G3 primary LNA mode is active-low: LOW/open is LNA on, HIGH/short is LNA off. - digitalWrite(P_PRIMARY_LNA_EN, enabled ? P_PRIMARY_LNA_EN_ACTIVE : !P_PRIMARY_LNA_EN_ACTIVE); -#endif - } - public: + LoRaFEMControl loRaFEMControl; + void begin() { ESP32Board::begin(); - -#ifdef P_PA1_EN - rtc_gpio_hold_dis((gpio_num_t)P_PA1_EN); - pinMode(P_PA1_EN, OUTPUT); - setPAModeHigh(false); -#endif - -#ifdef P_PRIMARY_LNA_EN - rtc_gpio_hold_dis((gpio_num_t)P_PRIMARY_LNA_EN); - pinMode(P_PRIMARY_LNA_EN, OUTPUT); - setPrimaryLNAControl(true); -#endif + loRaFEMControl.init(); esp_reset_reason_t reason = esp_reset_reason(); if (reason == ESP_RST_DEEPSLEEP) { @@ -56,23 +26,30 @@ public: } void setPrimaryLNAEnable(bool enabled) { - setPrimaryLNAControl(enabled); + loRaFEMControl.setLNAEnable(enabled); } void setPrimaryPAHighPower(bool enabled) { - setPAModeHigh(enabled); + loRaFEMControl.setPAGainEnable(enabled); } void onBeforeTransmit() override { ESP32Board::onBeforeTransmit(); - setPrimaryLNAControl(false); + loRaFEMControl.setTxModeEnable(); } void onAfterTransmit() override { ESP32Board::onAfterTransmit(); - setPrimaryLNAControl(true); + loRaFEMControl.setRxModeEnable(); } + bool setLoRaFemLnaEnabled(bool enable) override; + bool canControlLoRaFemLna() const override; + bool isLoRaFemLnaEnabled() const override; + bool setLoRaFemPaGainEnabled(bool enable) override; + bool canControlLoRaFemPaGain() const override; + bool isLoRaFemPaGainEnabled() const override; + void powerOff() override; uint16_t getBattMilliVolts() override { diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index e4a66a18..074d6a2e 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -17,11 +17,11 @@ build_flags = -D P_LORA_SCLK=12 -D P_LORA_MISO=14 -D P_LORA_MOSI=13 - -D P_PA1_EN=9 ; PA PL1 Mode: LOW/open is PA low, HIGH/short is PA high. + -D P_PA1_EN=9 ; PA PL1 Mode: LOW/open selects low level, HIGH/short selects high level. -D P_PA1_EN_ACTIVE=HIGH -D P_PRIMARY_LNA_EN=10 ; Primary Slot LNA Mode: LOW/open is LNA on, HIGH/short is LNA off. -D P_PRIMARY_LNA_EN_ACTIVE=LOW - -D LORA_TX_POWER=7 ; configured as 7dbm, because the final output will be ~27dbm (~0.5w) if the PA is enabled. + -D LORA_TX_POWER=7 ; SX1262 input power to the Station G3 PA; final output depends on PA PL1/PL2 level. -D MAX_LORA_TX_POWER=22 ; -D P_LORA_TX_LED=35 -D PIN_BOARD_SDA=5 From c58c9b2f31798b468bad861b021b395ed857c110 Mon Sep 17 00:00:00 2001 From: Adam Gessaman Date: Sat, 8 Aug 2026 14:53:57 -0700 Subject: [PATCH 12/40] fix(station-g3): apply FEM PA level at TX start PA PL1 re-targets the PA's DC-DC supply rail rather than selecting a logic-level gain, and the serial CLI is serviced on every main-loop pass regardless of whether a transmit is in flight. A `set radio.fem.txgain` write could therefore move the rail mid-transmit, while the SX1262 was still driving the PA at full input power. Record the requested level in setPAGainEnable() and drive the pin from setTxModeEnable(), which runs from onBeforeTransmit() ahead of startTransmit(). The level only matters while transmitting, so deferring costs nothing. Document that the pref is saved immediately but applied at the next transmit, so `get radio.fem.txgain` can lead the hardware until then. --- docs/cli_commands.md | 1 + variants/station_g3_esp32/LoRaFEMControl.cpp | 14 ++++++++++++-- variants/station_g3_esp32/LoRaFEMControl.h | 2 ++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 390c8e04..8772b929 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -303,6 +303,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore - This controls a software-selectable external LoRa FEM transmit gain where the board supports it. - On Station G3, remove the PA PL1 jumper to allow software control. `on` selects PA PL1 high/short and `off` selects PA PL1 low/open. The PA PL2 hardware jumper determines whether this switches between power levels 1/3 or 2/4. - Select an operating level and SX1262 transmit power that comply with local RF limits and the Station G3 power-supply requirements. +- The setting is saved immediately, but on Station G3 the level is applied to the hardware at the start of the next transmit, so that the PA supply rail is never re-targeted while the PA is being driven. `get` reports the configured state, which may lead the hardware until the node next transmits. --- diff --git a/variants/station_g3_esp32/LoRaFEMControl.cpp b/variants/station_g3_esp32/LoRaFEMControl.cpp index 04ac2ff8..8d4f90f6 100644 --- a/variants/station_g3_esp32/LoRaFEMControl.cpp +++ b/variants/station_g3_esp32/LoRaFEMControl.cpp @@ -7,7 +7,7 @@ void LoRaFEMControl::init() { #ifdef P_PA1_EN rtc_gpio_hold_dis((gpio_num_t)P_PA1_EN); pinMode(P_PA1_EN, OUTPUT); - setPAGainEnable(pa_gain_enabled); + applyPAGain(); #endif #ifdef P_PRIMARY_LNA_EN @@ -29,6 +29,10 @@ void LoRaFEMControl::setSleepModeEnable() { } void LoRaFEMControl::setTxModeEnable() { + // Latch the requested PA level here, before the SX1262 starts driving the PA. PA PL1 + // retargets the PA's DC-DC rail, so moving it mid-transmit collapses the supply while + // the PA is still driven at full input power. + applyPAGain(); #ifdef P_PRIMARY_LNA_EN digitalWrite(P_PRIMARY_LNA_EN, !P_PRIMARY_LNA_EN_ACTIVE); #endif @@ -46,9 +50,15 @@ void LoRaFEMControl::setLNAEnable(bool enabled) { } void LoRaFEMControl::setPAGainEnable(bool enabled) { + // Recorded only -- the pin is driven from setTxModeEnable(). The PA level only matters + // while transmitting, so deferring costs nothing and keeps the rail change out of an + // in-flight transmit (the CLI runs on every main-loop pass, including mid-TX). pa_gain_enabled = enabled; +} + +void LoRaFEMControl::applyPAGain() { #ifdef P_PA1_EN - digitalWrite(P_PA1_EN, enabled ? P_PA1_EN_ACTIVE : !P_PA1_EN_ACTIVE); + digitalWrite(P_PA1_EN, pa_gain_enabled ? P_PA1_EN_ACTIVE : !P_PA1_EN_ACTIVE); #endif } diff --git a/variants/station_g3_esp32/LoRaFEMControl.h b/variants/station_g3_esp32/LoRaFEMControl.h index 429d6127..f622de91 100644 --- a/variants/station_g3_esp32/LoRaFEMControl.h +++ b/variants/station_g3_esp32/LoRaFEMControl.h @@ -15,6 +15,8 @@ public: bool isPAGainEnabled() const { return pa_gain_enabled; } private: + void applyPAGain(); + bool lna_enabled = true; bool pa_gain_enabled = false; }; From b75a548d7f03c6404009274f2c58063a8e36cef9 Mon Sep 17 00:00:00 2001 From: taco Date: Sun, 9 Aug 2026 16:50:44 +1000 Subject: [PATCH 13/40] build fix for R1 Neo repeater added missing declarations for user_btn and NullDisplayDriver --- variants/muziworks_r1_neo/platformio.ini | 1 + variants/muziworks_r1_neo/target.cpp | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index 52dc3e38..2fbb8ad6 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -10,6 +10,7 @@ build_flags = ${nrf52_base.build_flags} -D NRF52_POWER_MANAGEMENT -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=NullDisplayDriver -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 diff --git a/variants/muziworks_r1_neo/target.cpp b/variants/muziworks_r1_neo/target.cpp index 68655c5d..f667a282 100644 --- a/variants/muziworks_r1_neo/target.cpp +++ b/variants/muziworks_r1_neo/target.cpp @@ -4,12 +4,17 @@ R1NeoBoard board; -DISPLAY_CLASS display; - RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); - WRAPPER_CLASS radio_driver(radio, board); +#ifdef DISPLAY_CLASS + NullDisplayDriver display; +#endif + +#ifdef PIN_USER_BTN +MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); From e8bbcd4580f6c5dd86e011edd7ffe48d1a069f8a Mon Sep 17 00:00:00 2001 From: taco Date: Sun, 9 Aug 2026 16:51:45 +1000 Subject: [PATCH 14/40] R1 neo: remove stray lib_deps entry --- variants/muziworks_r1_neo/platformio.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index 2fbb8ad6..c139e34a 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -101,7 +101,6 @@ build_src_filter = ${R1Neo.build_src_filter} +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${R1Neo.lib_deps} - ${rak4631.lib_deps} densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 From 39934d6999143397591f7c32b9fb468c2f336498 Mon Sep 17 00:00:00 2001 From: taco Date: Sun, 9 Aug 2026 18:33:27 +1000 Subject: [PATCH 15/40] minewsemi: build fix and platformio.ini tidy up fixes minewsemi repeater failing build due to user_btn missing. --- variants/minewsemi_me25ls01/platformio.ini | 18 +----------------- variants/minewsemi_me25ls01/target.cpp | 4 ++-- variants/minewsemi_me25ls01/target.h | 9 +++++---- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/variants/minewsemi_me25ls01/platformio.ini b/variants/minewsemi_me25ls01/platformio.ini index d115a1f1..1743e719 100644 --- a/variants/minewsemi_me25ls01/platformio.ini +++ b/variants/minewsemi_me25ls01/platformio.ini @@ -21,6 +21,7 @@ build_flags = ${nrf52_base.build_flags} -D ENV_INCLUDE_INA219=1 build_src_filter = ${nrf52_base.build_src_filter} + + + +<../variants/minewsemi_me25ls01> + debug_tool = jlink @@ -55,7 +56,6 @@ build_flags = ${me25ls01.build_flags} ;-D PIN_BUZZER=25 ;-D PIN_BUZZER_EN=37 build_src_filter = ${me25ls01.build_src_filter} - + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> @@ -63,10 +63,6 @@ build_src_filter = ${me25ls01.build_src_filter} [env:Minewsemi_me25ls01_repeater] extends = me25ls01 build_flags = ${me25ls01.build_flags} - -D MAX_CONTACTS=100 - -D MAX_GROUP_CHANNELS=8 - -D BLE_PIN_CODE=123456 -; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 @@ -85,10 +81,6 @@ build_src_filter = ${me25ls01.build_src_filter} [env:Minewsemi_me25ls01_room_server] extends = me25ls01 build_flags = ${me25ls01.build_flags} - -D MAX_CONTACTS=100 - -D MAX_GROUP_CHANNELS=8 -; -D BLE_PIN_CODE=123456 -; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 @@ -103,15 +95,10 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_room_server> - + [env:Minewsemi_me25ls01_terminal_chat] extends = me25ls01 build_flags = ${me25ls01.build_flags} - -D MAX_CONTACTS=100 - -D MAX_GROUP_CHANNELS=8 - -D BLE_PIN_CODE=123456 -; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 @@ -126,7 +113,6 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_secure_chat/main.cpp> - + [env:Minewsemi_me25ls01_companion_radio_usb] extends = me25ls01 @@ -137,7 +123,6 @@ build_flags = ${me25ls01.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 ;-D BLE_PIN_CODE=123456 -; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 @@ -147,7 +132,6 @@ build_flags = ${me25ls01.build_flags} -D ENABLE_USB_INTERFACE build_src_filter = ${me25ls01.build_src_filter} + - + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> diff --git a/variants/minewsemi_me25ls01/target.cpp b/variants/minewsemi_me25ls01/target.cpp index 9944a38b..41fc71a7 100644 --- a/variants/minewsemi_me25ls01/target.cpp +++ b/variants/minewsemi_me25ls01/target.cpp @@ -4,7 +4,6 @@ MinewsemiME25LS01Board board; RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); - WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock rtc_clock; @@ -18,7 +17,8 @@ extern EnvironmentSensorManager sensors; #endif #ifdef DISPLAY_CLASS - NullDisplayDriver display; + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif #ifndef LORA_CR diff --git a/variants/minewsemi_me25ls01/target.h b/variants/minewsemi_me25ls01/target.h index 978e616b..db44058c 100644 --- a/variants/minewsemi_me25ls01/target.h +++ b/variants/minewsemi_me25ls01/target.h @@ -11,16 +11,17 @@ #include #ifdef DISPLAY_CLASS #include -#endif - -#ifdef DISPLAY_CLASS - extern NullDisplayDriver display; + #include #endif extern MinewsemiME25LS01Board board; extern WRAPPER_CLASS radio_driver; extern VolatileRTCClock rtc_clock; extern EnvironmentSensorManager sensors; +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif bool radio_init(); mesh::LocalIdentity radio_new_identity(); From ce62c8b5d7fd90be844ab56e2f3a7d70983aaa23 Mon Sep 17 00:00:00 2001 From: taco Date: Sun, 9 Aug 2026 21:41:22 +1000 Subject: [PATCH 16/40] LR2021: add PREAMBLE_DETECTED bit and IRQ timeout logic --- src/helpers/radiolib/CustomLR2021.h | 59 +++++++++++++++++++++- src/helpers/radiolib/CustomLR2021Wrapper.h | 4 ++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 17944ef0..a89ae943 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -4,6 +4,10 @@ #include "MeshCore.h" class CustomLR2021 : public LR2021 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; bool _rx_boosted = false; public: @@ -66,11 +70,62 @@ class CustomLR2021 : public LR2021 { bool getRxBoostedGainMode() const { return _rx_boosted; } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return LR2021::startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { uint32_t irq = getIrqStatus(); - bool detected = ((irq & RADIOLIB_LR2021_IRQ_SYNCWORD_VALID) || (irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED)); - return detected; + bool preamble = irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; // bit 5 + bool header = irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID; // bit 6 + bool hdrErr = irq & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; // bit 9 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } + uint8_t getSpreadingFactor() const { return spreadingFactor; } }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 87989831..8299e2bf 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -22,6 +22,10 @@ public: ((CustomLR2021 *)_radio)->setCodingRate(cr); updatePreamble(sf); applySideDetectorConfig(); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomLR2021 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomLR2021 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + } bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override { From ecb8c945601ae348917b82eed9babfb8213989c2 Mon Sep 17 00:00:00 2001 From: Florent Date: Sun, 9 Aug 2026 14:46:23 -0400 Subject: [PATCH 17/40] nrf52: call nRFCrypto.begin()/end() only once --- src/Identity.cpp | 2 -- src/Utils.cpp | 12 ------------ src/helpers/NRF52Board.cpp | 13 +++++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 2 -- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/Identity.cpp b/src/Identity.cpp index 25419fd4..51a01ae7 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -27,11 +27,9 @@ bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) c // needs much less, around 600-700bytes. The CC310 workspace is static, faster, // should save power at scale as well. static CRYS_ECEDW_TempBuff_t cc310_tmp; - nRFCrypto.begin(); CRYSError_t rc = CRYS_ECEDW_Verify((uint8_t*)sig, CRYS_ECEDW_SIGNATURE_BYTES, (uint8_t*)pub_key, CRYS_ECEDW_MOD_SIZE_IN_BYTES, (uint8_t*)message, (size_t)msg_len, &cc310_tmp); - nRFCrypto.end(); return rc == CRYS_OK; #elif 0 // NOTE: memory corruption bug was found in this function!! diff --git a/src/Utils.cpp b/src/Utils.cpp index d4bc8c45..5ae7f0e2 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -24,9 +24,7 @@ uint32_t RNG::nextInt(uint32_t _min, uint32_t _max) { void Utils::sha256(uint8_t *hash, size_t hash_len, const uint8_t* msg, int msg_len) { #ifdef USE_CC310_HW_CRYPTO static CRYS_HASH_Result_t result; - nRFCrypto.begin(); CRYS_HASH(CRYS_HASH_SHA256_mode, (uint8_t*)msg, (size_t)msg_len, result); - nRFCrypto.end(); memcpy(hash, result, hash_len); #else SHA256 sha; @@ -39,12 +37,10 @@ void Utils::sha256(uint8_t *hash, size_t hash_len, const uint8_t* frag1, int fra #ifdef USE_CC310_HW_CRYPTO static CRYS_HASHUserContext_t ctx; static CRYS_HASH_Result_t result; - nRFCrypto.begin(); CRYS_HASH_Init(&ctx, CRYS_HASH_SHA256_mode); CRYS_HASH_Update(&ctx, (uint8_t*)frag1, (size_t)frag1_len); CRYS_HASH_Update(&ctx, (uint8_t*)frag2, (size_t)frag2_len); CRYS_HASH_Finish(&ctx, result); - nRFCrypto.end(); memcpy(hash, result, hash_len); #else SHA256 sha; @@ -62,7 +58,6 @@ int Utils::decrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s const uint8_t* sp = src; size_t dummy_out = 0; - nRFCrypto.begin(); SaSi_AesInit(&ctx, SASI_AES_DECRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE); SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData)); while (sp - src < src_len) { @@ -71,7 +66,6 @@ int Utils::decrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s } SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &dummy_out); SaSi_AesFree(&ctx); - nRFCrypto.end(); return sp - src; #else AES128 aes; @@ -95,7 +89,6 @@ int Utils::encrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s uint8_t* dp = dest; size_t dummy_out = 0; - nRFCrypto.begin(); SaSi_AesInit(&ctx, SASI_AES_ENCRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE); SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData)); while (src_len >= 16) { @@ -110,7 +103,6 @@ int Utils::encrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s } SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &dummy_out); SaSi_AesFree(&ctx); - nRFCrypto.end(); return dp - dest; #else AES128 aes; @@ -138,11 +130,9 @@ int Utils::encryptThenMAC(const uint8_t* shared_secret, uint8_t* dest, const uin #ifdef USE_CC310_HW_CRYPTO static CRYS_HMACUserContext_t hmac_ctx; static CRYS_HASH_Result_t hmac_result; - nRFCrypto.begin(); CRYS_HMAC_Init(&hmac_ctx, CRYS_HASH_SHA256_mode, (uint8_t*)shared_secret, PUB_KEY_SIZE); CRYS_HMAC_Update(&hmac_ctx, dest + CIPHER_MAC_SIZE, enc_len); CRYS_HMAC_Finish(&hmac_ctx, hmac_result); - nRFCrypto.end(); memcpy(dest, hmac_result, CIPHER_MAC_SIZE); #else SHA256 sha; @@ -162,11 +152,9 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin { static CRYS_HMACUserContext_t hmac_ctx; static CRYS_HASH_Result_t hmac_result; - nRFCrypto.begin(); CRYS_HMAC_Init(&hmac_ctx, CRYS_HASH_SHA256_mode, (uint8_t*)shared_secret, PUB_KEY_SIZE); CRYS_HMAC_Update(&hmac_ctx, (uint8_t*)(src + CIPHER_MAC_SIZE), src_len - CIPHER_MAC_SIZE); CRYS_HMAC_Finish(&hmac_ctx, hmac_result); - nRFCrypto.end(); memcpy(hmac, hmac_result, CIPHER_MAC_SIZE); } #else diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index b6c8fec5..eb88c897 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -5,6 +5,10 @@ #include #include +#ifdef USE_CC310_HW_CRYPTO +#include +#endif + static BLEDfu bledfu; static void connect_callback(uint16_t conn_handle) { @@ -21,6 +25,11 @@ static void disconnect_callback(uint16_t conn_handle, uint8_t reason) { void NRF52Board::begin() { startup_reason = BD_STARTUP_NORMAL; + + #ifdef USE_CC310_HW_CRYPTO + // CC310 TRNG is higher quality and environment-independent vs radio RSSI noise. + nRFCrypto.begin(); + #endif } #ifdef NRF52_POWER_MANAGEMENT @@ -352,6 +361,10 @@ void NRF52Board::shutdownPeripherals() { sensors.getLocationProvider()->stop(); } +#ifdef USE_CC310_HW_CRYPTO + nRFCrypto.end(); +#endif + // Flush serial buffers Serial.flush(); delay(100); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 99f5ebbd..5db1e41f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -93,9 +93,7 @@ public: void random(uint8_t* dest, size_t sz) override { #ifdef USE_CC310_HW_CRYPTO // CC310 TRNG is higher quality and environment-independent vs radio RSSI noise. - nRFCrypto.begin(); nRFCrypto.Random.generate(dest, (uint16_t)sz); - nRFCrypto.end(); #else for (int i = 0; i < sz; i++) { dest[i] = _radio->randomByte() ^ (::random(0, 256) & 0xFF); From 6f491f30332226a57291cce6c7c3864e931b374d Mon Sep 17 00:00:00 2001 From: Dan Theisen Date: Sun, 9 Aug 2026 18:04:24 -0700 Subject: [PATCH 18/40] RX Boosted Gain was set back to the compiled in value rather than the current/flash value set by the user when the ACG reset is triggered. This commit fixes this. --- src/helpers/radiolib/CustomLR1110Wrapper.h | 3 ++- src/helpers/radiolib/CustomSTM32WLxWrapper.h | 2 +- src/helpers/radiolib/CustomSX1262Wrapper.h | 4 ++-- src/helpers/radiolib/CustomSX1268Wrapper.h | 4 ++-- src/helpers/radiolib/LR11x0Reset.h | 4 ++-- src/helpers/radiolib/SX126xReset.h | 4 ++-- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 44230c61..e7aaeb93 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -19,7 +19,6 @@ public: ((CustomLR1110 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } - void doResetAGC() override { lr11x0ResetAGC((LR11x0 *)_radio, ((CustomLR1110 *)_radio)->getFreqMHz()); } bool isReceivingPacket() override { return ((CustomLR1110 *)_radio)->isReceiving(); } @@ -50,4 +49,6 @@ public: bool getRxBoostedGainMode() const override { return ((CustomLR1110 *)_radio)->getRxBoostedGainMode(); } + + void doResetAGC() override { lr11x0ResetAGC((LR11x0 *)_radio, ((CustomLR1110 *)_radio)->getFreqMHz(), getRxBoostedGainMode()); } }; diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index a792a877..a4ba4971 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -35,5 +35,5 @@ public: } uint8_t getSpreadingFactor() const override { return ((CustomSTM32WLx *)_radio)->spreadingFactor; } - void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } + void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } }; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index bfea50ec..be314471 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -41,12 +41,12 @@ public: ((CustomSX1262 *)_radio)->sleep(false); } - void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - bool setRxBoostedGainMode(bool en) override { return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomSX1262 *)_radio)->getRxBoostedGainMode(); } + + void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } }; diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 104ba08b..70f5dabd 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -38,12 +38,12 @@ public: } uint8_t getSpreadingFactor() const override { return ((CustomSX1268 *)_radio)->spreadingFactor; } - void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - bool setRxBoostedGainMode(bool en) override { return ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomSX1268 *)_radio)->getRxBoostedGainMode(); } + + void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } }; diff --git a/src/helpers/radiolib/LR11x0Reset.h b/src/helpers/radiolib/LR11x0Reset.h index d06ffc53..cdfc1f9f 100644 --- a/src/helpers/radiolib/LR11x0Reset.h +++ b/src/helpers/radiolib/LR11x0Reset.h @@ -5,7 +5,7 @@ // Full receiver reset for LR11x0-family chips (LR1110, LR1120, LR1121). // Warm sleep powers down analog, calibrate(0x3F) refreshes all calibration blocks, // then re-applies RX settings that calibration may reset. -inline void lr11x0ResetAGC(LR11x0* radio, float freqMHz) { +inline void lr11x0ResetAGC(LR11x0* radio, float freqMHz, bool rx_boost_gain) { radio->sleep(true, 0); radio->standby(RADIOLIB_LR11X0_STANDBY_RC, true); @@ -16,6 +16,6 @@ inline void lr11x0ResetAGC(LR11x0* radio, float freqMHz) { radio->calibrateImageRejection(freqMHz - 4.0f, freqMHz + 4.0f); #ifdef RX_BOOSTED_GAIN - radio->setRxBoostedGainMode(RX_BOOSTED_GAIN); + radio->setRxBoostedGainMode(rx_boost_gain); #endif } diff --git a/src/helpers/radiolib/SX126xReset.h b/src/helpers/radiolib/SX126xReset.h index 39ddb73e..472eb33b 100644 --- a/src/helpers/radiolib/SX126xReset.h +++ b/src/helpers/radiolib/SX126xReset.h @@ -5,7 +5,7 @@ // Full receiver reset for all SX126x-family chips (SX1262, SX1268, LLCC68, STM32WLx). // Warm sleep powers down analog, Calibrate(0x7F) refreshes ADC/PLL/image calibration, // then re-applies RX settings that calibration may reset. -inline void sx126xResetAGC(SX126x* radio) { +inline void sx126xResetAGC(SX126x* radio, bool rx_boost_gain) { radio->sleep(true); radio->standby(RADIOLIB_SX126X_STANDBY_RC, true); @@ -26,7 +26,7 @@ inline void sx126xResetAGC(SX126x* radio) { radio->setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); #endif #ifdef SX126X_RX_BOOSTED_GAIN - radio->setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); + radio->setRxBoostedGainMode(rx_boost_gain); #endif #ifdef SX126X_REGISTER_PATCH uint8_t r_data = 0; From 820400495906f058994a5c9218a7fbf3a3181ec1 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 9 Aug 2026 19:07:25 -0700 Subject: [PATCH 19/40] fix(observer): leave AGC reset user-configurable --- examples/simple_repeater/MyMesh.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 41b70da1..87fc41bb 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1026,7 +1026,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled #ifdef WITH_MQTT_BRIDGE - _prefs.agc_reset_interval = 7; // 28 seconds (secs/4) — prevents AGC drift on long-running observers + // TODO: Re-enable this observer default once AGC reset preserves runtime + // radio.rxgain, or earlier if disabling it causes receiver regressions. + // _prefs.agc_reset_interval = 7; // 28 seconds (secs/4) #endif // Observer defaults (radio_watchdog, alert.*, snmp.*) moved to applyMQTTDefaults() // in MQTTDefaults.h — they live in /mqtt_prefs now, not NodePrefs. From 32bd6d48d6152420fc868de3edfd137cb4d92898 Mon Sep 17 00:00:00 2001 From: Wessel Nieboer Date: Mon, 10 Aug 2026 13:39:00 +0200 Subject: [PATCH 20/40] Fix T-beam Supreme S3 display --- src/helpers/ui/SH1106Display.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index c3840c02..8b91d857 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -24,8 +24,21 @@ bool SH1106Display::begin() { // Wire must already be initialised by board.begin() before this is called. // Boards with non-standard SH1106 addresses should define DISPLAY_ADDRESS - // in their variant/platformio configuration. - return i2c_probe(Wire, DISPLAY_ADDRESS) && display.begin(DISPLAY_ADDRESS, true); + // in their variant/platformio configuration. The SA0 strap selects 0x3C or + // 0x3D and differs between revisions of the same board (e.g. T-Beam + // Supreme), so fall back to the other address of the pair. + uint8_t addr = 0; + if (i2c_probe(Wire, DISPLAY_ADDRESS)) { + addr = DISPLAY_ADDRESS; + } else if (i2c_probe(Wire, DISPLAY_ADDRESS ^ 1)) { + addr = DISPLAY_ADDRESS ^ 1; + } + // Run the Adafruit init even when no panel answered: it is what allocates + // the frame buffer and the I2C device. Skipping it leaves i2c_dev and + // spi_dev NULL, and UITask::begin() calls turnOn() regardless of our + // return value, which then dereferences the null spi_dev. + bool ok = display.begin(addr ? addr : DISPLAY_ADDRESS, true); + return addr != 0 && ok; } void SH1106Display::turnOn() From 3138ad99ba7b4cd3b78c0f7a43b6bec6f67d3c94 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Mon, 10 Aug 2026 16:56:56 +0200 Subject: [PATCH 21/40] move RadioLibWrapper noise_floor level message under special debug flag --- src/helpers/radiolib/RadioLibWrappers.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index dc851e5c..b9c095ac 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -15,7 +15,7 @@ static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet // is transmitted by the module -static +static #if defined(ESP8266) || defined(ESP32) ICACHE_RAM_ATTR #endif @@ -100,7 +100,9 @@ void RadioLibWrapper::loop() { } _floor_sample_sum = 0; + #ifdef MESH_DEBUG_NOISE_FLOOR MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + #endif } } @@ -224,10 +226,10 @@ static float snr_threshold[] = { -17.5,// SF11 needs at least -17.5 dB SNR -20 // SF12 needs at least -20 dB SNR }; - + float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { if (sf < 7) return 0.0f; - + if (snr < snr_threshold[sf - 7]) return 0.0f; // Below threshold, no chance of success auto success_rate_based_on_snr = (snr - snr_threshold[sf - 7]) / 10.0; @@ -243,7 +245,7 @@ PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t // preamble + syncword + sfd + header uint32_t preamble_us = (((preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4; - + // airtime for max packet at current radio settings uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT); // airtime for payload only (no preamble, header or SOF) From a82dc1b9da98df20764cecb70275816c00d91150 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Mon, 10 Aug 2026 20:13:59 +0200 Subject: [PATCH 22/40] make promicro pinmap more tidy, move the LoRa pins to variant.h --- variants/promicro/PromicroBoard.h | 13 ------------- variants/promicro/variant.cpp | 32 +++++++++++++++++++++++-------- variants/promicro/variant.h | 18 +++++++++++++++-- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/variants/promicro/PromicroBoard.h b/variants/promicro/PromicroBoard.h index b190c47c..ee16c965 100644 --- a/variants/promicro/PromicroBoard.h +++ b/variants/promicro/PromicroBoard.h @@ -4,19 +4,6 @@ #include #include -#define P_LORA_NSS 13 //P1.13 45 -#define P_LORA_DIO_1 11 //P0.10 10 -#define P_LORA_RESET 10 //P0.09 9 -#define P_LORA_BUSY 16 //P0.29 29 -#define P_LORA_MISO 15 //P0.02 2 -#define P_LORA_SCLK 12 //P1.11 43 -#define P_LORA_MOSI 14 //P1.15 47 -#define SX126X_POWER_EN 21 //P0.13 13 -#define SX126X_RXEN 2 //P0.17 -#define SX126X_TXEN RADIOLIB_NC -#define SX126X_DIO2_AS_RF_SWITCH true -#define SX126X_DIO3_TCXO_VOLTAGE (1.8f) - #define PIN_VBAT_READ 17 #define ADC_MULTIPLIER (1.815f) // dependent on voltage divider resistors. TODO: more accurate battery tracking diff --git a/variants/promicro/variant.cpp b/variants/promicro/variant.cpp index 0a4c3aac..b7d281ac 100644 --- a/variants/promicro/variant.cpp +++ b/variants/promicro/variant.cpp @@ -3,13 +3,29 @@ #include "wiring_digital.h" const uint32_t g_ADigitalPinMap[] = { - 8, 6, 17, 20, 22, 24, 32, 11, 36, 38, - 9, 10, 43, 45, 47, 2, 29, 31, - 33, 34, 37, - 13, 15 + 8, // P0.08 = GPIO 0 + 6, // P0.06 = GPIO 1 + 17, // P0.17 = GPIO 2 + 20, // P0.20 = GPIO 3 + 22, // P0.22 = GPIO 4 + 24, // P0.24 = GPIO 5 + 32, // P1.00 = GPIO 6 + 11, // P0.11 = GPIO 7 + 36, // P1.04 = GPIO 8 + 38, // P1.06 = GPIO 9 + 9, // P0.09 = GPIO 10 + 10, // P0.10 = GPIO 11 + 43, // P1.11 = GPIO 12 + 45, // P1.13 = GPIO 13 + 47, // P1.15 = GPIO 14 + 2, // P0.02 = GPIO 15 + 29, // P0.29 = GPIO 16 + 31, // P0.31 = GPIO 17 + 33, // P1.01 = GPIO 18 + 34, // P1.02 = GPIO 19 + 37, // P1.05 = GPIO 20 + 13, // P0.13 = GPIO 21 + 15 // P0.15 = GPIO 22 }; -void initVariant() -{ -} - +void initVariant() {} diff --git a/variants/promicro/variant.h b/variants/promicro/variant.h index 98489da1..e8a78331 100644 --- a/variants/promicro/variant.h +++ b/variants/promicro/variant.h @@ -7,9 +7,9 @@ #pragma once #include "WVariant.h" - + //////////////////////////////////////////////////////////////////////////////// - // Low frequency clock source + // Low frequency clock source #define VARIANT_MCK (64000000ul) @@ -79,4 +79,18 @@ #define PIN_BUTTON1 (6) #define BUTTON_PIN PIN_BUTTON1 +////////////////////////////////////////////////////////////////////////////// +// LoRa +#define P_LORA_NSS (13) +#define P_LORA_DIO_1 (11) +#define P_LORA_RESET (10) +#define P_LORA_BUSY (16) +#define P_LORA_MISO (15) +#define P_LORA_SCLK (12) +#define P_LORA_MOSI (14) +#define SX126X_POWER_EN (21) +#define SX126X_RXEN (2) +#define SX126X_TXEN (-1) +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_DIO3_TCXO_VOLTAGE (1.8f) From e2aa7b98f9b586adb806756f2cc5e5a6a534fb7e Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 10 Aug 2026 11:59:26 -0700 Subject: [PATCH 23/40] feat(companion_radio): add external FEM gain preferences for RX and TX for companions Introduced consistent preferences for external LoRa FEM RX and TX gain settings in NodePrefs. Updated companion MyMesh to apply these settings during initialization and transmission. Added unit tests to verify the round-trip serialization of these new preferences. --- examples/companion_radio/MyMesh.cpp | 4 + examples/companion_radio/NodePrefs.h | 7 +- src/helpers/CommonCLI.cpp | 3 +- .../test_companion_node_prefs.cpp | 80 +++++++++++++++++++ .../test_config_serializer.cpp | 65 ++++++++++++--- 5 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 test/test_companion_node_prefs/test_companion_node_prefs.cpp diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b03334c1..2c334066 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -885,6 +885,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default + _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_txgain = 0; //_prefs.rx_delay_base = 10.0f; enable once new algo fixed _prefs.setRepeatEn(false); #if defined(USE_SX1262) || defined(USE_SX1268) @@ -974,6 +976,8 @@ void MyMesh::begin(bool has_display) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 39a5386a..5e67daaa 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -32,6 +32,8 @@ public: uint32_t gps_interval = 0; // GPS read interval in seconds uint8_t autoadd_config = 0; // bitmask for auto-add contacts config uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) + uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) + uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain (low by default) uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t autoadd_max_hops = 0; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) @@ -50,7 +52,8 @@ private: //def("cad", _parent->cad_enabled); //def("int_thr", _parent->interference_threshold); def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->rx_boosted_gain); + def("fem_rxgain", _parent->radio_fem_rxgain); + def("fem_txgain", _parent->radio_fem_txgain); def("tx", _parent->tx_power_dbm); def("af", _parent->airtime_factor); def("rxdelay", _parent->rx_delay_base); @@ -133,4 +136,4 @@ public: // new accessor methods bool isRepeatEn() const { return repeat.disable_fwd == 0; } void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } -}; \ No newline at end of file +}; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 56a52a0b..b318bb58 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -102,8 +102,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 - file.read((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 295 - // next: 296 + // next: 295 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp new file mode 100644 index 00000000..6d3cebdf --- /dev/null +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -0,0 +1,80 @@ +#include + +#include +#include +#include + +#include "../../examples/companion_radio/NodePrefs.h" + +class ReplayStream : public Stream { + const char* _text; + int _pos = 0; + int _len; + +public: + explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } + + int available() override { return _len - _pos; } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +class CaptureStream : public Stream { + std::string _text; + + size_t emit(long long value) { + char text[24]; + int length = snprintf(text, sizeof(text), "%lld", value); + return write(reinterpret_cast(text), length); + } + +public: + size_t write(uint8_t value) override { + _text.push_back(static_cast(value)); + return 1; + } + + size_t write(const uint8_t* buffer, size_t size) override { + _text.append(reinterpret_cast(buffer), size); + return size; + } + + size_t print(unsigned char value, int = DEC) override { return emit(value); } + size_t print(int value, int = DEC) override { return emit(value); } + size_t print(unsigned int value, int = DEC) override { return emit(value); } + size_t print(long value, int = DEC) override { return emit(value); } + size_t print(unsigned long value, int = DEC) override { return emit(value); } + size_t print(long long value, int = DEC) override { return emit(value); } + size_t print(unsigned long long value, int = DEC) override { return emit(value); } + + const std::string& text() const { return _text; } +}; + +TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) { + NodePrefs saved; + saved.rx_boosted_gain = 0; + saved.radio_fem_rxgain = 1; + saved.radio_fem_txgain = 0; + + CaptureStream output; + ASSERT_TRUE(saved.saveSerial(output)); + EXPECT_NE(std::string::npos, output.text().find("rxgain:0")); + EXPECT_NE(std::string::npos, output.text().find("fem_rxgain:1")); + EXPECT_NE(std::string::npos, output.text().find("fem_txgain:0")); + + ReplayStream input("{radio:{rxgain:1,fem_rxgain:0,fem_txgain:1}}"); + NodePrefs loaded; + loaded.rx_boosted_gain = 0; + loaded.radio_fem_rxgain = 1; + loaded.radio_fem_txgain = 0; + + ASSERT_TRUE(loaded.loadSerial(input)); + EXPECT_EQ(1, loaded.rx_boosted_gain); + EXPECT_EQ(0, loaded.radio_fem_rxgain); + EXPECT_EQ(1, loaded.radio_fem_txgain); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 7a13f487..27c3c811 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -1,6 +1,14 @@ #include #include "helpers/ConfigSerializer.h" +class NativeFileSystem { +public: + void mkdir(const char*) { } +}; +#define FILESYSTEM NativeFileSystem +#include "helpers/CommonCLI.h" +#undef FILESYSTEM + #define TEST_INT_S "56" #define TEST_INT 56 #define TEST_FLOAT_S "-6.123" @@ -21,6 +29,19 @@ public: class MockPrintStream : public Stream { int len = 0; uint8_t _buf[1024]; + + size_t printSigned(long long value) { + char text[24]; + snprintf(text, sizeof(text), "%lld", value); + return Print::print(text); + } + + size_t printUnsigned(unsigned long long value) { + char text[24]; + snprintf(text, sizeof(text), "%llu", value); + return Print::print(text); + } + public: size_t write(uint8_t b) override { if (len < sizeof(_buf)) { @@ -30,17 +51,17 @@ public: return 0; } - size_t print(unsigned char b, int r) override { if (b == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(int v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(unsigned int v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(unsigned long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(long long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(unsigned long long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } - size_t print(double v, int p = 2) override { - if (p == 6) return Print::print(TEST_DOUBLE_S); - if (p == 4) return Print::print(TEST_FLOAT_S); - return 0; + size_t print(unsigned char v, int r) override { return printUnsigned(v); } + size_t print(int v, int r) override { return printSigned(v); } + size_t print(unsigned int v, int r) override { return printUnsigned(v); } + size_t print(long v, int r) override { return printSigned(v); } + size_t print(unsigned long v, int r) override { return printUnsigned(v); } + size_t print(long long v, int r) override { return printSigned(v); } + size_t print(unsigned long long v, int r) override { return printUnsigned(v); } + size_t print(double v, int p = 2) override { + char text[32]; + snprintf(text, sizeof(text), "%.*f", p, v); + return Print::print(text); } int getLength() const { return len; } @@ -171,6 +192,28 @@ TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) { EXPECT_TRUE(match); } +TEST(NodePrefs, FemGainSettingsRoundTrip) { + NodePrefs saved; + saved.radio_fem_rxgain = 0; + saved.radio_fem_txgain = 1; + + MockPrintStream output; + ASSERT_TRUE(saved.saveSerial(output)); + + std::string serialised(reinterpret_cast(output.getBytes()), output.getLength()); + EXPECT_NE(std::string::npos, serialised.find("fem_rxgain:0")); + EXPECT_NE(std::string::npos, serialised.find("fem_txgain:1")); + + MockInputStream input(serialised.c_str()); + NodePrefs loaded; + loaded.radio_fem_rxgain = 1; + loaded.radio_fem_txgain = 0; + + ASSERT_TRUE(loaded.loadSerial(input)); + EXPECT_EQ(0, loaded.radio_fem_rxgain); + EXPECT_EQ(1, loaded.radio_fem_txgain); +} + // ── main ─────────────────────────────────────────────────────── From 28dd599fa8280bbf3dd461410f1b20775756be30 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 10 Aug 2026 12:50:11 -0700 Subject: [PATCH 24/40] fix(observer): recover T-Beam Supreme display startup --- examples/simple_repeater/main.cpp | 10 ++++++--- examples/simple_room_server/main.cpp | 10 ++++++--- src/helpers/ui/SH1106Display.cpp | 22 +++++++++++++++++-- src/helpers/ui/SH1106Display.h | 8 +++++-- .../platformio.ini | 1 + 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 991fffb1..06873d31 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -6,6 +6,7 @@ #ifdef DISPLAY_CLASS #include "UITask.h" static UITask ui_task(board, display); + static bool display_ready = false; #endif #ifdef ETHERNET_ENABLED @@ -52,7 +53,8 @@ void setup() { #endif #ifdef DISPLAY_CLASS - if (display.begin()) { + display_ready = display.begin(); + if (display_ready) { display.startFrame(); display.setCursor(0, 0); display.print("Please wait..."); @@ -112,7 +114,9 @@ void setup() { the_mesh.begin(fs); #ifdef DISPLAY_CLASS - ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + if (display_ready) { + ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + } #endif #ifdef ETHERNET_ENABLED @@ -193,7 +197,7 @@ void loop() { the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS - ui_task.loop(); + if (display_ready) ui_task.loop(); #endif rtc_clock.tick(); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index d833fff3..6b5f74a9 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -11,6 +11,7 @@ #ifdef DISPLAY_CLASS #include "UITask.h" static UITask ui_task(display); + static bool display_ready = false; #endif StdRNG fast_rng; @@ -37,7 +38,8 @@ void setup() { #endif #ifdef DISPLAY_CLASS - if (display.begin()) { + display_ready = display.begin(); + if (display_ready) { display.startFrame(); display.setCursor(0, 0); display.print("Please wait..."); @@ -88,7 +90,9 @@ void setup() { the_mesh.begin(fs); #ifdef DISPLAY_CLASS - ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + if (display_ready) { + ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + } #endif #ifdef ETHERNET_ENABLED @@ -151,7 +155,7 @@ void loop() { the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS - ui_task.loop(); + if (display_ready) ui_task.loop(); #endif rtc_clock.tick(); #ifdef HAS_EXTERNAL_WATCHDOG diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index c3840c02..2d190279 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -24,18 +24,36 @@ bool SH1106Display::begin() { // Wire must already be initialised by board.begin() before this is called. // Boards with non-standard SH1106 addresses should define DISPLAY_ADDRESS - // in their variant/platformio configuration. - return i2c_probe(Wire, DISPLAY_ADDRESS) && display.begin(DISPLAY_ADDRESS, true); + // in their variant/platformio configuration. Some board revisions may have + // different solder-bridge address configurations, so variants can also + // provide DISPLAY_ADDRESS_ALT as a fallback. + _initialized = false; + if (i2c_probe(Wire, DISPLAY_ADDRESS) && display.begin(DISPLAY_ADDRESS, true)) { + _initialized = true; + } +#ifdef DISPLAY_ADDRESS_ALT + if (!_initialized && DISPLAY_ADDRESS_ALT != DISPLAY_ADDRESS && + i2c_probe(Wire, DISPLAY_ADDRESS_ALT) && + display.begin(DISPLAY_ADDRESS_ALT, true)) { + _initialized = true; + } +#endif + return _initialized; } void SH1106Display::turnOn() { + if (!_initialized) return; display.oled_command(SH110X_DISPLAYON); _isOn = true; } void SH1106Display::turnOff() { + if (!_initialized) { + _isOn = false; + return; + } display.oled_command(SH110X_DISPLAYOFF); _isOn = false; } diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 4e269d5e..ddbcf80a 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -17,16 +17,20 @@ class SH1106Display : public DisplayDriver { Adafruit_SH1106G display; + bool _initialized; bool _isOn; uint8_t _color; bool i2c_probe(TwoWire &wire, uint8_t addr); public: - SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; } + SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { + _initialized = false; + _isOn = false; + } bool begin(); - bool isOn() override { return _isOn; } + bool isOn() override { return _initialized && _isOn; } void turnOn() override; void turnOff() override; void clear() override; diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index d509c4f7..811e4e93 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -20,6 +20,7 @@ build_flags = -D WRAPPER_CLASS=CustomSX1262Wrapper -D DISPLAY_CLASS=SH1106Display -D DISPLAY_ADDRESS=0x3D + -D DISPLAY_ADDRESS_ALT=0x3C -D LORA_TX_POWER=22 -D P_LORA_TX_LED=6 -D PIN_BOARD_SDA=17 From c5bf3e990aaa29dd27cba16fa65a95e433a4bc61 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 6 Aug 2026 07:54:39 -0700 Subject: [PATCH 25/40] docs(mqtt): record the non-PSRAM TLS footprint investigation Documents why the largest allocatable block on a non-PSRAM observer walks down in 16 KiB steps at every TLS reconnect, and what to do about it. The framework builds mbedTLS with the symmetric buffer configuration (CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN 16384), so each broker slot holds two 16 KiB record buffers in internal DRAM and two slots cost 64 KiB on a board with about 80 KiB free. Confirmed against the sdkconfig the Heltec env actually links, not the IDF 5.3 libs package that belongs to the esp32c6 env. Rebuilding only the mbedTLS archives with an asymmetric outbound buffer of 4 KiB is ABI-safe, because in_buf/out_buf are heap pointers sized in ssl_setup() and no public struct embeds a CONTENT_LEN-sized array. Dropping KEEP_PEER_CERTIFICATE is not, since it changes mbedtls_ssl_session layout, so it is excluded. Also records two traps worth not rediscovering: esp32-arduino-lib-builder's release/v4.4 clones its dependencies at master and no longer resolves, and pointing platform_packages at a file:// framework copy overwrites the shared package for every other project on the machine. --- docs/mbedtls-tls-footprint.md | 153 ++++++++++++++++++++++++++++++++++ platformio.local.ini.hold | 15 ++++ 2 files changed, 168 insertions(+) create mode 100644 docs/mbedtls-tls-footprint.md create mode 100644 platformio.local.ini.hold diff --git a/docs/mbedtls-tls-footprint.md b/docs/mbedtls-tls-footprint.md new file mode 100644 index 00000000..0985c4f6 --- /dev/null +++ b/docs/mbedtls-tls-footprint.md @@ -0,0 +1,153 @@ +# Shrinking the per-connection TLS footprint on non-PSRAM observers + +## Why + +On a non-PSRAM Heltec V3 running two WSS/JWT broker slots, the largest allocatable block in +internal DRAM walks down in ~16 KiB steps at every TLS reconnect while total free heap stays +flat. Measured on hardware over 50 reconnect cycles: 62,452 → 16,372 bytes, permanently. + +The step size is not a coincidence. `framework-arduinoespressif32 3.20017` (Arduino 2.0.17, +IDF 4.4) builds mbedTLS with the **symmetric** buffer configuration: + +``` +CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN 16384 # sizes BOTH the in and out record buffers +CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE 1 +# CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN — not defined +# CONFIG_MBEDTLS_DYNAMIC_BUFFER — not defined +# CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH — not defined +``` + +Read from `packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_qspi/include/sdkconfig.h`. +Note the separate `framework-arduinoespressif32-libs` package in `~/.platformio` belongs to +the esp32c6 env (pioarduino, IDF 5.3) and is **not** what this env links against — though it +happens to carry identical mbedTLS values. + +So each broker slot costs **2 × 16 KiB = 32 KiB** of contiguous internal DRAM in record +buffers alone, and two slots cost 64 KiB on a board with roughly 80 KiB free. Every reconnect +frees and re-allocates those buffers, and anything that lands in the vacated hole in between +prevents them from going back, which is the ratchet. + +Confirmed by two independent observations: losing a whole TLS session returned exactly 16,384 +bytes of contiguity on one device and exactly 32,768 on another, and per-connection teardown +frees ~41.6–44.7 KB total. + +## What the firmware could already do, and its limit + +`softDisconnect()` (branch `perf/mqtt-renewal-no-stop`, commit `6c51e468`) stops the JWT +renewal bounce from destroying and recreating the esp-mqtt task, keeping its 6 KiB stack out +of the hole. Measured: the staircase arrests after 2 steps at 36,852 through cycle 16, where +the baseline took 4 steps and settled at 16,372 by cycle 11 — about 20 KB better. + +That is as far as the application layer reaches. MQTT 3.1.1 has no re-authentication packet, +so presenting a fresh JWT *requires* a transport reconnect; mbedTLS's internal allocation +order during the handshake is not controllable from the application. The remaining cost is +the record buffers themselves. + +## The changes + +All three are compile-time in mbedTLS, and the Arduino framework ships precompiled `.a` +archives (`tools/sdk/esp32s3/lib/libmbedtls.a`), so a project-level `-D` cannot change them. +A custom framework build is required. + +| Setting | From | To | Saving per connection | +|---|---|---|---| +| `CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN` | unset | `y` | enables the two below | +| `CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN` | 16384 (implied) | 16384 | none — keep it | +| `CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN` | 16384 (implied) | 4096 | **~12 KiB** | +| `CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE` | `1` | `n` | ~4 KiB | + +Roughly 16 KiB per connection, 32 KiB across two slots. + +### Why inbound stays at 16 KiB + +A peer may legitimately send a 16 KiB TLS record. Lowering the inbound limit only works if +both brokers negotiate the max-fragment-length extension or provably never send larger, and +getting it wrong produces invalid-record and handshake failures rather than a clean error. +Outbound is ours to choose: this firmware's MQTT and WebSocket frames are far below 4 KiB +(`MAX_TRANS_UNIT`-bounded packets plus small JSON), so 4 KiB is comfortable. + +### Risk on the peer certificate + +Dropping `KEEP_PEER_CERTIFICATE` means `mbedtls_ssl_get_peer_cert()` returns NULL after the +handshake. Chain validation still happens — only retention of the parsed leaf changes. This +firmware verifies against a CA (`GTS_ROOT_R4` / the bundle) and never inspects the peer +certificate or a fingerprint, so it should be safe. It does change `mbedtls_ssl_session` +layout, which is exactly why the whole framework must be rebuilt together rather than +swapping a single archive in. + +## Build procedure + +### Do not use esp32-arduino-lib-builder for this + +`release/v4.4` is the branch matching Arduino 2.0.x, but its `update-components.sh` clones +every dependency at **master**, so it no longer resolves. Five successive failures, each a +different repo: the arduino branch name it passes to `-A` does not exist; `jq` is absent +from the IDF image and its absence makes `build.sh`'s target loop a **silent no-op that +still exits 0**; `esp_littlefs` and `esp32-camera` master require IDF ≥5.0/≥5.1; +`esp32-camera` later needs an `esp_jpeg` version the 4.4 registry cannot satisfy; and +tinyusb's source layout no longer matches `arduino_tinyusb/CMakeLists.txt`. Pinning each +one in turn just surfaces the next. + +### Rebuild only the mbedTLS archives + +More rigorous anyway, because it reuses the shipped `sdkconfig` verbatim — so the archives +differ from stock *only* by the intended change, with no arduino-version or +`DYNAMIC_BUFFER` drift. + +This is ABI-safe for the content-length change specifically: `ssl.h` declares `in_buf` and +`out_buf` as `unsigned char *`, allocated in `ssl_setup()`, and no public struct embeds a +CONTENT_LEN-sized array. The other precompiled archives (esp-tls, esp_http_client, +esp-mqtt) therefore remain compatible. **It is not safe for +`CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE`**, which changes `mbedtls_ssl_session` layout — +that one needs everything rebuilt together, so it is excluded here. + +1. Minimal IDF project whose only component requirement is `mbedtls`. +2. `sdkconfig.defaults` = the shipped + `packages/framework-arduinoespressif32/tools/sdk/esp32s3/sdkconfig`, with + `CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=16384` replaced by the three asymmetric lines. + Verify the diff is exactly 5 lines before building. +3. `docker run --rm -v $PWD:/project -w /project espressif/idf:v4.4.7 idf.py -DIDF_TARGET=esp32s3 build` +4. Confirm `build/config/sdkconfig.h` carries `OUT_CONTENT_LEN 4096`. +5. Stage the four archives under the framework's names — note the rename: + + | built | framework name | stock size | rebuilt | + |---|---|---:|---:| + | `esp-idf/mbedtls/libmbedtls.a` | `libmbedtls.a` | 113,914 | 113,338 | + | `esp-idf/mbedtls/mbedtls/library/libmbedtls.a` | **`libmbedtls_2.a`** | 1,252,150 | 1,245,718 | + | `.../libmbedcrypto.a` | `libmbedcrypto.a` | 4,302,698 | 4,259,458 | + | `.../libmbedx509.a` | `libmbedx509.a` | 676,590 | 673,318 | + + All within ~1% of stock, which is a good check that only the config differs. + +### Wire it in with -L, never platform_packages + +``` +PLATFORMIO_BUILD_FLAGS="-L/path/to/staged/archives" pio run -e Heltec_v3_repeater_observer_mqtt +``` + +Verify with `grep -oE "[^ ]*libmbed[a-z0-9_]*\.a" .pio/build//firmware.map | sort -u` — +every path must be the staged directory. + +**Do not** point `platform_packages` at a `file://` copy of the framework. PlatformIO +installs it *over* the shared `~/.platformio/packages/framework-arduinoespressif32`, +silently changing mbedTLS for every other ESP32 env and project on the machine. It does this +even when the copy's `package.json` version differs — verified twice here, and both times the +fix was `rm -rf` the package and `pio pkg install` to re-download stock. A prepended library +search path keeps the change scoped to one env, because the linker takes each archive member +from the first archive that satisfies an undefined symbol. + +## How to verify it worked + +1. `strings`/`grep` the new `sdkconfig.h` for the four settings. +2. Build and check the RAM figure; static usage should be unchanged (these are heap buffers). +3. On hardware, `get mqtt.stats` at boot with two slots connected: the largest free block + should start roughly 24–32 KiB higher than the current 62–67 KiB. +4. Soak across reconnect cycles and compare the floor against the two recorded runs: + baseline settled 16,372 (cycle 11); `softDisconnect` holds 36,852 (cycle 16). + +## Prior art in this investigation + +`.scratch/mqtt-non-psram-heap-staircase-analysis-2026-08-05.md` (untracked — `.scratch/` is +globally gitignored) holds the full allocation inventory. `~/mqtt-soak/STATE.md` holds the +soak evidence, including two retracted hypotheses worth not repeating: the perf commits were +not the cause, and waev does not cap connections per IP. diff --git a/platformio.local.ini.hold b/platformio.local.ini.hold new file mode 100644 index 00000000..e0d89578 --- /dev/null +++ b/platformio.local.ini.hold @@ -0,0 +1,15 @@ +; Local-only override (gitignored) pointing the Heltec V3 observer env at a custom +; framework whose mbedTLS archives were rebuilt with an asymmetric TLS record buffer: +; CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y +; CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384 (unchanged — a peer may send a 16 KiB record) +; CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 (was 16384) +; +; Expected: ~12 KiB less internal DRAM per TLS connection, ~24 KiB across two broker slots. +; Built from the shipped sdkconfig verbatim plus those three lines, so the archives differ +; only by this change. See docs/mbedtls-tls-footprint.md. +; +; Absolute path, hence local-only: not committable. + +[env:Heltec_v3_repeater_observer_mqtt] +platform_packages = + framework-arduinoespressif32 @ file:///Users/adam/framework-arduinoespressif32-tlsfix From 5cce5cfe9766585fab8559054ebffd8933a847c4 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Tue, 11 Aug 2026 10:37:20 +0200 Subject: [PATCH 26/40] removed misleading 'GPIO' from pin numbers --- variants/promicro/variant.cpp | 46 +++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/variants/promicro/variant.cpp b/variants/promicro/variant.cpp index b7d281ac..69b61745 100644 --- a/variants/promicro/variant.cpp +++ b/variants/promicro/variant.cpp @@ -3,29 +3,29 @@ #include "wiring_digital.h" const uint32_t g_ADigitalPinMap[] = { - 8, // P0.08 = GPIO 0 - 6, // P0.06 = GPIO 1 - 17, // P0.17 = GPIO 2 - 20, // P0.20 = GPIO 3 - 22, // P0.22 = GPIO 4 - 24, // P0.24 = GPIO 5 - 32, // P1.00 = GPIO 6 - 11, // P0.11 = GPIO 7 - 36, // P1.04 = GPIO 8 - 38, // P1.06 = GPIO 9 - 9, // P0.09 = GPIO 10 - 10, // P0.10 = GPIO 11 - 43, // P1.11 = GPIO 12 - 45, // P1.13 = GPIO 13 - 47, // P1.15 = GPIO 14 - 2, // P0.02 = GPIO 15 - 29, // P0.29 = GPIO 16 - 31, // P0.31 = GPIO 17 - 33, // P1.01 = GPIO 18 - 34, // P1.02 = GPIO 19 - 37, // P1.05 = GPIO 20 - 13, // P0.13 = GPIO 21 - 15 // P0.15 = GPIO 22 + 8, // P0.08 = 0 + 6, // P0.06 = 1 + 17, // P0.17 = 2 + 20, // P0.20 = 3 + 22, // P0.22 = 4 + 24, // P0.24 = 5 + 32, // P1.00 = 6 + 11, // P0.11 = 7 + 36, // P1.04 = 8 + 38, // P1.06 = 9 + 9, // P0.09 = 10 + 10, // P0.10 = 11 + 43, // P1.11 = 12 + 45, // P1.13 = 13 + 47, // P1.15 = 14 + 2, // P0.02 = 15 + 29, // P0.29 = 16 + 31, // P0.31 = 17 + 33, // P1.01 = 18 + 34, // P1.02 = 19 + 37, // P1.05 = 20 + 13, // P0.13 = 21 + 15 // P0.15 = 22 }; void initVariant() {} From 9c60f9696ecdab55c1527ca2235bfc2c022bb290 Mon Sep 17 00:00:00 2001 From: taco Date: Wed, 12 Aug 2026 14:04:50 +1000 Subject: [PATCH 27/40] t096 compiler layout workaround see https://github.com/meshcore-dev/MeshCore/issues/3151 --- src/helpers/CommonCLI.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2a9ec43b..3143acd2 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -79,7 +79,8 @@ private: def("bw", _parent->bw); def("sf", _parent->sf); def("cr", _parent->cr); - def("cad", _parent->cad_enabled); + static const char radio_cad_key[] = {'c', 'a', 'd', '\0'}; // workaround for T096, don't touch without testing T096 repeater still transmit + def(radio_cad_key, _parent->cad_enabled); def("int_thr", _parent->interference_threshold); def("rxgain", _parent->rx_boosted_gain); def("fem_rxgain", _parent->rx_boosted_gain); From 82de18fb0e1b74382fb1c190ad62de0c0aef1e60 Mon Sep 17 00:00:00 2001 From: taco Date: Wed, 12 Aug 2026 14:07:31 +1000 Subject: [PATCH 28/40] T096 PA_CTX pin direction workaround --- variants/heltec_t096/LoRaFEMControl.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/heltec_t096/LoRaFEMControl.cpp b/variants/heltec_t096/LoRaFEMControl.cpp index fb60a8f7..45bbfd64 100644 --- a/variants/heltec_t096/LoRaFEMControl.cpp +++ b/variants/heltec_t096/LoRaFEMControl.cpp @@ -21,6 +21,7 @@ void LoRaFEMControl::setSleepModeEnable(void) void LoRaFEMControl::setTxModeEnable(void) { + pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT); // force pinMode before transmit as temporary workaround for https://github.com/meshcore-dev/MeshCore/issues/3151 digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); } From 9d6dcc673e4fdc12cd67c0fde1487dbed9e6c39f Mon Sep 17 00:00:00 2001 From: taco Date: Wed, 12 Aug 2026 16:08:05 +1000 Subject: [PATCH 29/40] set T-Echo Card TCXO voltage to 3.0 as per https://github.com/meshcore-dev/MeshCore/pull/3140 and https://github.com/Xinyuan-LilyGO/T-Echo-Lite/issues/14 --- variants/lilygo_techo_card/variant.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/lilygo_techo_card/variant.h b/variants/lilygo_techo_card/variant.h index 28c5f550..699453e0 100644 --- a/variants/lilygo_techo_card/variant.h +++ b/variants/lilygo_techo_card/variant.h @@ -107,7 +107,7 @@ #define P_LORA_NSS (11) // P0.11 #define SX126X_RXEN (33) // P1.01 #define SX126X_TXEN (27) // P0.27 -#define SX126X_DIO3_TCXO_VOLTAGE (1.8f) +#define SX126X_DIO3_TCXO_VOLTAGE (3.0f) //////////////////////////////////////////////////////////////////////////////// From 264d77816864b2a4f7c67e6e47869895cbe23f86 Mon Sep 17 00:00:00 2001 From: taco Date: Wed, 12 Aug 2026 22:28:05 +1000 Subject: [PATCH 30/40] Revert "T096 PA_CTX pin direction workaround" This reverts commit 82de18fb0e1b74382fb1c190ad62de0c0aef1e60. --- variants/heltec_t096/LoRaFEMControl.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/variants/heltec_t096/LoRaFEMControl.cpp b/variants/heltec_t096/LoRaFEMControl.cpp index 45bbfd64..fb60a8f7 100644 --- a/variants/heltec_t096/LoRaFEMControl.cpp +++ b/variants/heltec_t096/LoRaFEMControl.cpp @@ -21,7 +21,6 @@ void LoRaFEMControl::setSleepModeEnable(void) void LoRaFEMControl::setTxModeEnable(void) { - pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT); // force pinMode before transmit as temporary workaround for https://github.com/meshcore-dev/MeshCore/issues/3151 digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); } From f43e1cde8938c38371fdf93a51dc1c8b4e289384 Mon Sep 17 00:00:00 2001 From: taco Date: Wed, 12 Aug 2026 22:28:36 +1000 Subject: [PATCH 31/40] Revert "t096 compiler layout workaround" This reverts commit 9c60f9696ecdab55c1527ca2235bfc2c022bb290. --- src/helpers/CommonCLI.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3143acd2..2a9ec43b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -79,8 +79,7 @@ private: def("bw", _parent->bw); def("sf", _parent->sf); def("cr", _parent->cr); - static const char radio_cad_key[] = {'c', 'a', 'd', '\0'}; // workaround for T096, don't touch without testing T096 repeater still transmit - def(radio_cad_key, _parent->cad_enabled); + def("cad", _parent->cad_enabled); def("int_thr", _parent->interference_threshold); def("rxgain", _parent->rx_boosted_gain); def("fem_rxgain", _parent->rx_boosted_gain); From 49d9f9a83931815bb937a8ef72f74161e7d2a45d Mon Sep 17 00:00:00 2001 From: taco Date: Wed, 12 Aug 2026 22:31:57 +1000 Subject: [PATCH 32/40] Heltec T096: fix PIN_SPI1_MISO Setting SPI pins to values that are OOB of the g_ADigitalPinMap[] array causes OOB reads. This variant.h has pin 0 as 0xFF which passes through as NRFX_SPIM_PIN_NOT_USED. --- variants/heltec_t096/variant.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/heltec_t096/variant.h b/variants/heltec_t096/variant.h index c240c1f2..bfbf2e63 100644 --- a/variants/heltec_t096/variant.h +++ b/variants/heltec_t096/variant.h @@ -104,7 +104,7 @@ #define PIN_SPI_SCK (32 + 8) #define PIN_SPI_NSS LORA_CS -#define PIN_SPI1_MISO (-1) +#define PIN_SPI1_MISO (0) #define PIN_SPI1_MOSI (0+17) #define PIN_SPI1_SCK (0+20) From 3ea541f1f232c493039c15eb995f17321251605c Mon Sep 17 00:00:00 2001 From: taco Date: Thu, 13 Aug 2026 00:12:37 +1000 Subject: [PATCH 33/40] Fix Heltec T1 pin definitions SPI pins that are set to -1 cause OOB reads on NRF52. The unused Serial2 pin definitions were removed to avoid potential issues with the Uart framework. --- variants/heltec_t1/variant.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/variants/heltec_t1/variant.h b/variants/heltec_t1/variant.h index 7ee21816..b52aeba0 100644 --- a/variants/heltec_t1/variant.h +++ b/variants/heltec_t1/variant.h @@ -25,8 +25,8 @@ #define ST7735_SDA (0 + 24) #define ST7735_SCK (32 + 0) #define ST7735_RESET (0 + 20) -#define ST7735_MISO (-1) -#define ST7735_BUSY (-1) +#define ST7735_MISO (0) // 0 maps to 0xff on this device which is NRFX_SPIM_PIN_NOT_USED +#define ST7735_BUSY (0) // 0 maps to 0xff on this device which is NRFX_SPIM_PIN_NOT_USED #define ST7735_BL (0 + 15) #define VTFT_CTRL (0 + 13) @@ -66,8 +66,7 @@ // UART // No longer populated on PCB. -#define PIN_SERIAL2_RX (-1) -#define PIN_SERIAL2_TX (-1) +// Removed the pin definitions to avoid potential issues with Uart. //////////////////////////////////////////////////////////////////////////////// // I2C From c2a4ef082f1013eb527efeccc699f196f51f504e Mon Sep 17 00:00:00 2001 From: taco Date: Thu, 13 Aug 2026 00:15:34 +1000 Subject: [PATCH 34/40] Fix Heltec MeshPocket SPI pins --- variants/mesh_pocket/variant.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/mesh_pocket/variant.h b/variants/mesh_pocket/variant.h index 870d062a..9774276c 100644 --- a/variants/mesh_pocket/variant.h +++ b/variants/mesh_pocket/variant.h @@ -108,7 +108,7 @@ #define PIN_DISPLAY_DC (31) #define PIN_DISPLAY_RST (32 + 4) -#define PIN_SPI1_MISO (-1) +#define PIN_SPI1_MISO (0) // 0 maps to 0xff on this device which is NRFX_SPIM_PIN_NOT_USED #define PIN_SPI1_MOSI (20) #define PIN_SPI1_SCK (22) From 7cd1d207af4a645246dc0c0390e54b5e7c335744 Mon Sep 17 00:00:00 2001 From: taco Date: Thu, 13 Aug 2026 00:53:58 +1000 Subject: [PATCH 35/40] Fix LilyGo T-Echo Lite SPI pins --- variants/lilygo_techo_lite/variant.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/variants/lilygo_techo_lite/variant.h b/variants/lilygo_techo_lite/variant.h index 38b79c66..83bb6f07 100644 --- a/variants/lilygo_techo_lite/variant.h +++ b/variants/lilygo_techo_lite/variant.h @@ -58,7 +58,7 @@ #define PIN_SPI_MISO _PINNUM(0, 17) // (MISO) #define PIN_SPI_MOSI _PINNUM(0, 15) // (MOSI) #define PIN_SPI_SCK _PINNUM(0, 13) // (SCK) -#define PIN_SPI_NSS (-1) +#define PIN_SPI_NSS (0) //////////////////////////////////////////////////////////////////////////////// // QSPI FLASH @@ -123,7 +123,7 @@ //////////////////////////////////////////////////////////////////////////////// // SPI1 -#define PIN_SPI1_MISO (-1) // Not used for Display +#define PIN_SPI1_MISO (0) // Not used for Display, 0 maps to 0xff on this device which is NRFX_SPIM_PIN_NOT_USED #define PIN_SPI1_MOSI _PINNUM(0, 20) #define PIN_SPI1_SCK _PINNUM(0, 19) @@ -135,7 +135,7 @@ extern const int SCK; //////////////////////////////////////////////////////////////////////////////// // Display -// #define DISP_MISO (-1) // Not used for Display +// #define DISP_MISO (0) // Not used for Display, 0 maps to 0xff on this device which is NRFX_SPIM_PIN_NOT_USED #define DISP_MOSI _PINNUM(0, 20) #define DISP_SCLK _PINNUM(0, 19) #define DISP_CS _PINNUM(0, 22) @@ -143,7 +143,7 @@ extern const int SCK; #define DISP_RST _PINNUM(0, 28) #define DISP_BUSY _PINNUM(0, 3) #define DISP_POWER _PINNUM(1, 12) -// #define DISP_BACKLIGHT (-1) // Display has no backlight +// #define DISP_BACKLIGHT (0) // Display has no backlight, 0 maps to 0xff on this device which is NRFX_SPIM_PIN_NOT_USED #define PIN_DISPLAY_CS DISP_CS #define PIN_DISPLAY_DC DISP_DC From 890a2e2cffa98dea3ca88db3c3aba72ef23ff3f7 Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Fri, 14 Aug 2026 16:30:12 +1000 Subject: [PATCH 36/40] * commenting out the load/save of the fem_ properties, until they can be set --- examples/companion_radio/NodePrefs.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 453f28d5..21766de8 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -53,8 +53,12 @@ private: //def("cad", _parent->cad_enabled); //def("int_thr", _parent->interference_threshold); def("rxgain", _parent->rx_boosted_gain); + #if 0 + // NOTE: these cannot be set (yet) so don't load/save until we can. + // also, fem_rxgain WAS mapped to wrong JSON property previously def("fem_rxgain", _parent->radio_fem_rxgain); def("fem_txgain", _parent->radio_fem_txgain); + #endif def("tx", _parent->tx_power_dbm); def("af", _parent->airtime_factor); def("rxdelay", _parent->rx_delay_base); From e78bff0041393e508f37a4fa45ffd46f5bcb4f90 Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Fri, 14 Aug 2026 16:42:54 +1000 Subject: [PATCH 37/40] * unit test no longer valid --- test/test_companion_node_prefs/test_companion_node_prefs.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 6d3cebdf..433c9710 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -50,6 +50,8 @@ public: const std::string& text() const { return _text; } }; +#if 0 +// Re-enable test once we can SET fem_ values in companion TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) { NodePrefs saved; saved.rx_boosted_gain = 0; @@ -73,6 +75,7 @@ TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) { EXPECT_EQ(0, loaded.radio_fem_rxgain); EXPECT_EQ(1, loaded.radio_fem_txgain); } +#endif int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); From fcb9bdf2095f4f39d8ed433e1fb8df4278a4880f Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Fri, 14 Aug 2026 17:46:43 +1000 Subject: [PATCH 38/40] * combine radio's entropy with CC310 RNG --- src/helpers/radiolib/RadioLibWrappers.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 5db1e41f..77dd9311 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -92,8 +92,10 @@ public: void random(uint8_t* dest, size_t sz) override { #ifdef USE_CC310_HW_CRYPTO - // CC310 TRNG is higher quality and environment-independent vs radio RSSI noise. nRFCrypto.Random.generate(dest, (uint16_t)sz); + for (int i = 0; i < sz; i++) { + dest[i] ^= _radio->randomByte() ^ (::random(0, 256) & 0xFF); // combine with Radio's entropy + } #else for (int i = 0; i < sz; i++) { dest[i] = _radio->randomByte() ^ (::random(0, 256) & 0xFF); From d92964352441e53b93e8667b802e04f6e072b39e Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Fri, 14 Aug 2026 22:19:19 +1000 Subject: [PATCH 39/40] * version 1.17.1 --- examples/companion_radio/MyMesh.h | 4 ++-- examples/simple_repeater/MyMesh.h | 4 ++-- examples/simple_room_server/MyMesh.h | 4 ++-- examples/simple_sensor/SensorMesh.h | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f73fe0e0..238adada 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 13 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "9 Aug 2026" +#define FIRMWARE_BUILD_DATE "14 Aug 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.17.0" +#define FIRMWARE_VERSION "v1.17.1" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 6d9cf459..04bd4fb9 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -71,11 +71,11 @@ struct NeighbourInfo { }; #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE "9 Aug 2026" + #define FIRMWARE_BUILD_DATE "14 Aug 2026" #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.17.0" + #define FIRMWARE_VERSION "v1.17.1" #endif #define FIRMWARE_ROLE "repeater" diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index ba9f7eec..5cf949c6 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -28,11 +28,11 @@ /* ------------------------------ Config -------------------------------- */ #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE "9 Aug 2026" + #define FIRMWARE_BUILD_DATE "14 Aug 2026" #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.17.0" + #define FIRMWARE_VERSION "v1.17.1" #endif #ifndef LORA_FREQ diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 3a33b638..b5e96d5c 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -34,11 +34,11 @@ #define PERM_RECV_ALERTS_HI (1 << 7) // high priority alerts #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE "9 Aug 2026" + #define FIRMWARE_BUILD_DATE "14 Aug 2026" #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.17.0" + #define FIRMWARE_VERSION "v1.17.1" #endif #define FIRMWARE_ROLE "sensor" From 47c80fecf3a522add73e910f8e59300b63489bf5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 08:10:42 -0700 Subject: [PATCH 40/40] ci: bump observer channel base version to v1.17.1 Both channels move together so a node comparing its embedded base against its own channel's manifest does not read as permanently behind. Resets the per-base build counter to N=1 on both channels. Nodes still on v1.17.0.N take the diff_base branch in ESP32Board's OTA comparison, so the counter going backwards does not strand them. --- .github/workflows/build-observer-firmwares-beta.yml | 2 +- .github/workflows/build-observer-firmwares.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-observer-firmwares-beta.yml b/.github/workflows/build-observer-firmwares-beta.yml index 43c2ee17..8618da4b 100644 --- a/.github/workflows/build-observer-firmwares-beta.yml +++ b/.github/workflows/build-observer-firmwares-beta.yml @@ -56,7 +56,7 @@ env: # OTA comparison treats a different base version as "always an update", so a # distinct base here would make every beta node think it is permanently behind. # Channels are separated by manifest URL, not by base version. - FIRMWARE_VERSION: v1.17.0 + FIRMWARE_VERSION: v1.17.1 # Beta-only rolling release. A separate tag is required, not cosmetic: the # publish step prunes all but the KEEP_BUILDS most recent build hashes within diff --git a/.github/workflows/build-observer-firmwares.yml b/.github/workflows/build-observer-firmwares.yml index 68dd184a..13d9cda0 100644 --- a/.github/workflows/build-observer-firmwares.yml +++ b/.github/workflows/build-observer-firmwares.yml @@ -41,7 +41,7 @@ concurrency: env: # Version embedded in firmware filenames; must match the version key in the # flasher's config.json. Bump here when the observer version changes. - FIRMWARE_VERSION: v1.17.0 + FIRMWARE_VERSION: v1.17.1 # Rolling release tag that hosts the latest observer binaries. RELEASE_TAG: observer-mqtt-latest # Download host serving RELEASE_TAG's assets (cloudflare-worker in the flasher