merge: observer-firmware-dev into observer-firmware at v1.17.1
Promotes the v1.17.1 upstream sync and observer-firmware-dev work onto the production channel. Conflict in SH1106Display.cpp: kept the observer-firmware-dev begin() that always runs Adafruit init and falls back across the 0x3C/0x3D SA0 pair, rather than the earlier production DISPLAY_ADDRESS_ALT-only probe.
This commit is contained in:
commit
6ff49201da
63 files changed
+1610
-430
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <state>`
|
||||
|
||||
**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
|
||||
|
||||
@@ -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/<env>/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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
#include <helpers/StatsFormatHelper.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <helpers/RegionMap.h>
|
||||
#include <helpers/RoutingPolicy.h>
|
||||
#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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <helpers/StatsFormatHelper.h>
|
||||
#include <helpers/ClientACL.h>
|
||||
#include <helpers/RegionMap.h>
|
||||
#include <helpers/RoutingPolicy.h>
|
||||
#include <RTClib.h>
|
||||
#include <target.h>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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!!
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "MeshCore.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include "MQTTPayloadBuilder.h"
|
||||
#include "MQTTWireScratch.h"
|
||||
#include <Mesh.h>
|
||||
#include <Timezone.h>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<JsonObject>();
|
||||
|
||||
root["origin"] = origin;
|
||||
|
||||
@@ -61,6 +61,7 @@ public:
|
||||
);
|
||||
|
||||
static int buildRawMessage(
|
||||
JsonDocument& doc,
|
||||
const char* origin,
|
||||
const char* origin_id,
|
||||
const char* timestamp,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <Packet.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// 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
|
||||
@@ -5,6 +5,10 @@
|
||||
#include <bluefruit.h>
|
||||
#include <nrf_soc.h>
|
||||
|
||||
#ifdef USE_CC310_HW_CRYPTO
|
||||
#include <Adafruit_nRFCrypto.h>
|
||||
#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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <Packet.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
+376
-185
@@ -12,6 +12,7 @@
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
#include <math.h>
|
||||
#include <new>
|
||||
#include <strings.h>
|
||||
|
||||
#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<uint8_t*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
|
||||
_last_raw_data, LAST_RAW_DATA_SIZE, psram_malloc));
|
||||
_publish_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::allocateIfMissing(
|
||||
_publish_json_buffer, PUBLISH_JSON_BUFFER_SIZE, psram_malloc));
|
||||
_status_json_buffer = static_cast<char*>(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<char*>(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<uint8_t*>(MQTTRuntimeBufferLifecycle::release(
|
||||
_last_raw_data, psram_free));
|
||||
_publish_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
|
||||
_publish_json_buffer, psram_free));
|
||||
_status_json_buffer = static_cast<char*>(MQTTRuntimeBufferLifecycle::release(
|
||||
_status_json_buffer, psram_free));
|
||||
_json_scratch_buffer = static_cast<char*>(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<char*>(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<char*>(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<char*>(
|
||||
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<uint32_t>(now_millis),
|
||||
static_cast<uint32_t>(_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<uint32_t>(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<uint8_t>(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<uint8_t>(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) {
|
||||
|
||||
@@ -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<uint32_t> _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<PUBLISH_JSON_BUFFER_SIZE> _packet_json_doc;
|
||||
StaticJsonDocument<STATUS_JSON_BUFFER_SIZE> _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<N> 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();
|
||||
|
||||
|
||||
@@ -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()); }
|
||||
};
|
||||
@@ -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()); }
|
||||
};
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()); }
|
||||
};
|
||||
@@ -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()); }
|
||||
};
|
||||
@@ -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()); }
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#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<const uint8_t*>(text), length);
|
||||
}
|
||||
|
||||
public:
|
||||
size_t write(uint8_t value) override {
|
||||
_text.push_back(static_cast<char>(value));
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t write(const uint8_t* buffer, size_t size) override {
|
||||
_text.append(reinterpret_cast<const char*>(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();
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
#include <gtest/gtest.h>
|
||||
#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<const char*>(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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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<size_t>(len), strlen(buffer));
|
||||
@@ -224,8 +225,9 @@ TEST(MQTTPayloadBuilder, MaximumRepresentativePacketAndRawPayloadsRemainValid) {
|
||||
EXPECT_STREQ("3c3d3e3f", parsed_path[15].as<const char*>());
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 <gtest/gtest.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#include <gtest/gtest.h>
|
||||
#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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ build_flags = ${nrf52_base.build_flags}
|
||||
-D ENV_INCLUDE_INA219=1
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<helpers/*.cpp>
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
+<../variants/minewsemi_me25ls01>
|
||||
+<helpers/sensors>
|
||||
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}
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
+<helpers/nrf52/SerialBLEInterface.cpp>
|
||||
+<../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>
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
|
||||
[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>
|
||||
+<helpers/ui/NullDisplayDriver.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}
|
||||
+<helpers/nrf52/*.cpp>
|
||||
+<helpers/ui/NullDisplayDriver.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-orig/*.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
|
||||
|
||||
@@ -11,16 +11,17 @@
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <helpers/ui/NullDisplayDriver.h>
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern NullDisplayDriver display;
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#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();
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -4,19 +4,6 @@
|
||||
#include <Arduino.h>
|
||||
#include <helpers/NRF52Board.h>
|
||||
|
||||
#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
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "LoRaFEMControl.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -3,45 +3,15 @@
|
||||
#include <Arduino.h>
|
||||
#include <helpers/ESP32Board.h>
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
#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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user