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 diff --git a/docs/cli_commands.md b/docs/cli_commands.md index f3779c8a..057fc0e2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -305,6 +305,22 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a --- +#### 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. +- 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. + +--- + ### System #### View or change this node's name 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/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 9e389e99..0a14cbfb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -886,6 +886,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) @@ -975,6 +977,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"); // NOTE: no FEM LNA wiring here — companion has its own NodePrefs without 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/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4ec1803e..21766de8 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -33,6 +33,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) @@ -51,7 +53,12 @@ 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); + #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); @@ -135,4 +142,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/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 41b70da1..7e209cfb 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -460,24 +460,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"); @@ -677,17 +684,29 @@ 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 != 0xFF, 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 == 0xFF) { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + 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) { + sendDirect(reply, reply_path, reply_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 { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendDirect(reply, reply_path, reply_path_len, SERVER_RESPONSE_DELAY); + sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } } } @@ -1026,7 +1045,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. @@ -1057,6 +1078,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif #endif _prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards) + _prefs.radio_fem_txgain = 0; // LoRa FEM TX gain off by default (FEM boards) _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') pending_discover_tag = 0; @@ -1198,6 +1220,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); // LoRa FEM LNA (FEM boards only) + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 4a7d5252..a232ef52 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -46,6 +46,7 @@ #include #include #include +#include #include "RateLimiter.h" @@ -79,11 +80,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.cpp b/examples/simple_room_server/MyMesh.cpp index 546fed45..9fe5a9c8 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -354,10 +354,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; } @@ -889,6 +888,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #endif #endif _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_txgain = 0; // Observer defaults (alert.*, etc.) moved to applyMQTTDefaults() — they live // in /mqtt_prefs now, not NodePrefs. @@ -965,6 +965,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); // LoRa FEM LNA (FEM boards only) + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -1053,15 +1054,23 @@ bool MyMesh::resolveAlertScope(TransportKey& dest) { } 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 fb28605f..b0eb4e8f 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -38,11 +39,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.cpp b/examples/simple_sensor/SensorMesh.cpp index 097cfc35..b497fc85 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -737,6 +737,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)); } @@ -773,6 +774,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); // LoRa FEM LNA (FEM boards only) + board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); updateAdvertTimer(); updateFloodAdvertTimer(); 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" 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 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/MeshCore.h b/src/MeshCore.h index 05f7d021..15ccdc15 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -74,6 +74,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/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/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e579ba24..427d456b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -352,6 +352,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _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(); @@ -1251,6 +1252,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]; @@ -1532,6 +1555,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 7bee051b..e0a0a71b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -66,6 +66,7 @@ public: char owner_info[120]; uint8_t rx_boosted_gain = 0; // power settings uint8_t radio_fem_rxgain = 0; // LoRa FEM RX-gain (LNA); hardware driving is wired per-board + 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) @@ -88,6 +89,7 @@ private: def("int_thr", _parent->interference_threshold); def("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/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index 3ec13277..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( @@ -182,10 +183,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 +258,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 @@ -298,6 +298,7 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw( } int MQTTMessageBuilder::buildRawJSON( + JsonDocument& doc, mesh::Packet* packet, const char* origin, const char* origin_id, @@ -314,11 +315,10 @@ 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); + + return buildRawMessage(doc, origin, origin_id, timestamp, raw_hex, buffer, buffer_size); } const char* MQTTMessageBuilder::getRouteTypeString(int route_type) { @@ -356,9 +356,10 @@ 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]; + if (!canSerializePacket(packet, 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..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,6 +22,15 @@ */ class MQTTMessageBuilder { public: + // 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 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). * Always UTC with an explicit "+00:00" offset, ISO-8601 @@ -146,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, @@ -232,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..d5c79336 --- /dev/null +++ b/src/helpers/MQTTWireScratch.h @@ -0,0 +1,47 @@ +#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 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; +} + +} // namespace MQTTWireScratch diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index d37a2279..bc3854e4 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/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/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 9f1ecab3..735999e9 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 @@ -177,6 +178,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; @@ -474,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"; @@ -609,7 +647,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 +663,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 @@ -654,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; @@ -712,8 +750,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 +761,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 +785,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 +999,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 +1019,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) @@ -1005,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 @@ -1083,7 +1119,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 +1173,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) { @@ -1367,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; } @@ -1380,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) { @@ -1546,119 +1579,199 @@ 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 _status_json_doc/_status_json_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; +} + +// 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. +// +// 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]; + 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; + } + // 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; +} + +// 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, psram_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); } } -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; } - // 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); - return; + // 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; } // Reconfigure path: if we're re-applying (e.g. after a preset change), stop @@ -1685,7 +1798,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; @@ -1715,12 +1828,16 @@ 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[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); + slot.last_reconnect_attempt = millis(); + 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 @@ -1829,11 +1946,13 @@ 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) - createSlotAuthToken(index); - if (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); + slot.last_reconnect_attempt = millis(); + 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); @@ -1842,6 +1961,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 @@ -1861,7 +1981,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'; @@ -1907,8 +2029,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) || @@ -1917,6 +2043,37 @@ 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)) { + // 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; + } + if (!_slots[i].client) continue; + maintainSlotConnection(i, now_millis, current_time, time_synced, reconnect_attempted_this_cycle, teardown_attempted_this_cycle); } } @@ -1930,7 +2087,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), @@ -2029,8 +2186,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); @@ -2130,6 +2287,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]; @@ -2169,7 +2330,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 +2358,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 +2375,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 +2441,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 +2501,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 +2516,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); } @@ -2429,6 +2591,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; @@ -2450,6 +2619,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); } } @@ -2636,10 +2812,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; } @@ -2647,7 +2822,6 @@ void MQTTBridge::loop() { continue; } setupSlot(i); - active_count++; } } } @@ -3093,7 +3267,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]; @@ -3122,7 +3299,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; @@ -3144,12 +3324,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 +3379,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 +3397,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 +3471,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 +3497,37 @@ 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. 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 (MQTTMessageBuilder::canSerializePacket(packet, 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 +3543,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 +3575,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]; @@ -3408,6 +3592,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 ); @@ -3418,7 +3603,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 +3653,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; } } @@ -3740,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) { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 143aae36..eb7ab943 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -86,9 +86,16 @@ 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; 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 + // 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; @@ -176,9 +183,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 +289,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 +327,26 @@ 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 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; + 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 @@ -356,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 @@ -383,25 +412,37 @@ 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 + 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); 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(); diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index ae0fe0a2..24754076 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -35,12 +35,12 @@ public: } uint8_t getSpreadingFactor() const override { return ((CustomLLCC68 *)_radio)->spreadingFactor; } - void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - bool setRxBoostedGainMode(bool en) override { return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomLLCC68 *)_radio)->getRxBoostedGainMode(); } + + void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } }; 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/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 { 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/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index f2af3bce..1cf30486 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 } } @@ -230,10 +232,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; @@ -249,7 +251,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) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 38104fba..bf54491a 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -103,10 +103,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.begin(); nRFCrypto.Random.generate(dest, (uint16_t)sz); - nRFCrypto.end(); + 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); 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; diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 2d190279..a1652801 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -24,20 +24,26 @@ 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. 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; - } + // 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, or to DISPLAY_ADDRESS_ALT + // when a variant names one explicitly. #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; - } + const uint8_t alt_addr = DISPLAY_ADDRESS_ALT; +#else + const uint8_t alt_addr = DISPLAY_ADDRESS ^ 1; #endif + uint8_t addr = 0; + if (i2c_probe(Wire, DISPLAY_ADDRESS)) { + addr = DISPLAY_ADDRESS; + } else if (alt_addr != DISPLAY_ADDRESS && i2c_probe(Wire, alt_addr)) { + addr = alt_addr; + } + // 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 for any caller that draws without checking our return value. + bool ok = display.begin(addr ? addr : DISPLAY_ADDRESS, true); + _initialized = (addr != 0) && ok; return _initialized; } 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..433c9710 --- /dev/null +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -0,0 +1,83 @@ +#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; } +}; + +#if 0 +// Re-enable test once we can SET fem_ values in companion +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); +} +#endif + +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 ─────────────────────────────────────────────────────── 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..d8258aea --- /dev/null +++ b/test/test_mqtt_wire_scratch/test_mqtt_wire_scratch.cpp @@ -0,0 +1,132 @@ +// 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)); +} + +// 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; + 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)); +} + +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(); +} 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(); +} 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) 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 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) //////////////////////////////////////////////////////////////////////////////// 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 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) 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(); diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index 52dc3e38..c139e34a 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 @@ -100,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 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); 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..69b61745 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 = 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() -{ -} - +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) diff --git a/variants/station_g3_esp32/LoRaFEMControl.cpp b/variants/station_g3_esp32/LoRaFEMControl.cpp new file mode 100644 index 00000000..8d4f90f6 --- /dev/null +++ b/variants/station_g3_esp32/LoRaFEMControl.cpp @@ -0,0 +1,79 @@ +#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); + applyPAGain(); +#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() { + // 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 +} + +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) { + // 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, pa_gain_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..f622de91 --- /dev/null +++ b/variants/station_g3_esp32/LoRaFEMControl.h @@ -0,0 +1,22 @@ +#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: + void applyPAGain(); + + 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 02d0aa76..bca80963 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