Enhance MQTT bridge implementation by adding support for the TennMesh preset, including fixed username/password authentication. Update documentation to reflect new preset details and modify error messages in CommonCLI for improved clarity. Adjust MQTTBridge to handle new authentication methods and ensure proper connection management across all slots.

This commit is contained in:
agessaman committed 2026-04-05 22:38:56 -07:00
1 parent 1c524a9392
commit 3b46e9eeed
6 files changed
+70 -55

No files matched your search

+13 -11
View File
@@ -77,10 +77,10 @@ get mqtt.status
The MQTT bridge implementation provides:
- Up to 6 MQTT connection slots with built-in presets
- Built-in presets for LetsMesh Analyzer (US/EU), MeshMapper, MeshRank, Waev, Meshomatic, and CascadiaMesh
- Built-in presets for LetsMesh Analyzer (US/EU), MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, and TennMesh
- Custom broker support with username/password authentication
- JWT (Ed25519 device signing) authentication for preset brokers
- WSS (WebSocket Secure) and direct MQTT transport
- JWT (Ed25519 device signing) authentication for most preset brokers; TennMesh uses a fixed username/password (plain MQTT)
- WSS (WebSocket Secure), direct MQTT/TLS, and plain MQTT (TennMesh) transport
- Automatic reconnection with exponential backoff
- JSON message formatting for status, packets, and raw data
- Packet queuing during connection issues
@@ -103,6 +103,7 @@ The MQTT bridge uses a slot-based architecture with up to 6 concurrent connectio
| `waev` | mqtt.waev.app:443 | JWT (Ed25519) | WSS |
| `meshomatic` | us-east.meshomatic.net:443 | JWT (Ed25519) | WSS |
| `cascadiamesh` | mqtt-v1.cascadiamesh.org:443 | JWT (Ed25519) | WSS |
| `tennmesh` | mqtt.tennmesh.com:1883 | Username/password (fixed in firmware) | Plain MQTT |
| `custom` | User-configured | Username/Password | MQTT or WSS |
| `none` | (disabled) | — | — |
@@ -186,7 +187,7 @@ You can flash the merged firmware using either the web flasher or the command li
The MQTT bridge comes with the following defaults for fresh installs:
- **Origin**: Device name (set automatically from `set name`)
- **IATA**: (blank — must be configured for Analyzer presets)
- **IATA**: (blank — must be configured for MeshCore-style topic presets such as Analyzer and TennMesh)
- **Status Messages**: Enabled
- **Packet Messages**: Enabled
- **Raw Messages**: Disabled
@@ -229,6 +230,7 @@ Each slot (1-6) supports the following commands:
- `set mqttN.preset waev` - Set slot N to Waev
- `set mqttN.preset meshomatic` - Set slot N to Meshomatic
- `set mqttN.preset cascadiamesh` - Set slot N to CascadiaMesh
- `set mqttN.preset tennmesh` - Set slot N to TennMesh (plain MQTT; same `meshcore/{iata}/...` topics as Analyzer US)
- `set mqttN.preset custom` - Set slot N to custom broker (configure server/port/username/password)
- `set mqttN.preset none` - Disable slot N
- `set mqttN.server <hostname>` - Set custom server hostname for slot N
@@ -305,7 +307,7 @@ When a slot's preset is `custom`, you can define a custom topic template using p
If no custom topic is set, custom slots default to: `meshcore/{iata}/{device}/{type}`
**Note:** Topic templates only apply to `custom` preset slots. Built-in presets (analyzer-us, analyzer-eu, meshmapper, meshrank, etc.) always use their hardcoded topic format.
**Note:** Topic templates only apply to `custom` preset slots. Built-in presets (analyzer-us, analyzer-eu, meshmapper, meshrank, tennmesh, etc.) always use their hardcoded topic format.
### MQTT Shared Commands
@@ -496,10 +498,10 @@ Minimal raw packet data for map integration.
### Slot-Based Preset System
- Up to 6 concurrent MQTT connections (with PSRAM), 2 without PSRAM
- Built-in presets for LetsMesh Analyzer (US/EU), MeshMapper, MeshRank, Waev, Meshomatic, and CascadiaMesh
- Built-in presets for LetsMesh Analyzer (US/EU), MeshMapper, MeshRank, Waev, Meshomatic, CascadiaMesh, and TennMesh
- Custom broker support with username/password auth and custom topic templates
- JWT (Ed25519) authentication for preset brokers, token-in-topic for MeshRank
- WSS (WebSocket Secure) and direct MQTT over TLS transport
- JWT (Ed25519) for most preset brokers; MeshRank uses token-in-topic; TennMesh uses fixed username/password over plain MQTT
- WSS (WebSocket Secure), direct MQTT over TLS, and plain MQTT (TennMesh)
- Automatic reconnection with exponential backoff per slot
- Circuit breaker pattern with periodic probes for recovery from prolonged outages
- JWT token buffers only allocated for JWT-auth slots (memory efficient)
@@ -529,8 +531,8 @@ Minimal raw packet data for map integration.
- Proper UTC system time handling
### Authentication
- **JWT Authentication**: Ed25519-signed tokens for secure MQTT authentication (used by all built-in presets except meshrank and cascadiamesh)
- **Username/Password**: Standard MQTT authentication for custom brokers
- **JWT Authentication**: Ed25519-signed tokens for brokers that expect JWT (most built-in presets; not MeshRank or TennMesh). For `custom` slots, JWT is used when `audience` is set.
- **Username/Password**: Custom brokers; TennMesh also uses fixed credentials embedded in the `tennmesh` preset (plain MQTT, no TLS)
- **Username Format** (JWT): `v1_{UPPERCASE_PUBLIC_KEY}`
- **Automatic Token Renewal**: Tokens are renewed before expiration
@@ -637,7 +639,7 @@ get mqtt1.diag # Last slot error details (TLS/sock/time)
get mqtt2.diag
get mqtt3.diag
get mqtt1.preset # Verify slots are configured
get mqtt.iata # IATA must be set for Analyzer presets
get mqtt.iata # IATA must be set for MeshCore-topic presets (e.g. Analyzer, TennMesh)
```
#### Timezone Issues
+1 -1
View File
@@ -1462,7 +1462,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
}
}
} else {
strcpy(reply, "Error: valid presets are: analyzer-us, analyzer-eu, meshmapper, meshrank, waev, cascadiamesh, custom, none");
strcpy(reply, "Error: valid presets are: analyzer-us, analyzer-eu, meshmapper, meshrank, waev, meshomatic, cascadiamesh, tennmesh, custom, none");
}
} else if (memcmp(subcmd, "server ", 7) == 0) {
StrHelper::strncpy(_prefs->mqtt_slot_host[slot], &subcmd[7], sizeof(_prefs->mqtt_slot_host[slot]));
+11 -8
View File
@@ -36,6 +36,8 @@ struct MQTTPresetDef {
unsigned long token_lifetime; // JWT token lifetime in seconds (0 = use default 86400)
bool allow_retain; // Whether the broker allows the MQTT retain flag
uint16_t keepalive; // MQTT keepalive in seconds (0 = library default 120s)
const char* userpass_username; // MQTT_AUTH_USERPASS: broker username; else nullptr
const char* userpass_password; // MQTT_AUTH_USERPASS: broker password; else nullptr
};
// Google Trust Services - GTS Root R4 (used by LetsMesh Analyzer)
@@ -97,17 +99,18 @@ static const char ISRG_ROOT_X1[] PROGMEM =
"-----END CERTIFICATE-----\n";
// Number of built-in presets
static const int MQTT_PRESET_COUNT = 7;
static const int MQTT_PRESET_COUNT = 8;
// Built-in preset definitions (stored in flash)
static const MQTTPresetDef MQTT_PRESETS[MQTT_PRESET_COUNT] = {
{ "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55 },
{ "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55 },
{ "meshmapper", "wss://mqtt.meshmapper.cc:443/mqtt", "mqtt.meshmapper.cc", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55 },
{ "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0 },
{ "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55 },
{ "meshomatic", "wss://us-east.meshomatic.net:443/mqtt", "us-east.meshomatic.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55 },
{ "cascadiamesh", "wss://mqtt-v1.cascadiamesh.org:443/mqtt", "mqtt-v1.cascadiamesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55 },
{ "analyzer-us", "wss://mqtt-us-v1.letsmesh.net:443/mqtt", "mqtt-us-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "analyzer-eu", "wss://mqtt-eu-v1.letsmesh.net:443/mqtt", "mqtt-eu-v1.letsmesh.net", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "meshmapper", "wss://mqtt.meshmapper.cc:443/mqtt", "mqtt.meshmapper.cc", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, true, 55, nullptr, nullptr },
{ "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false, 0, nullptr, nullptr },
{ "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false, 55, nullptr, nullptr },
{ "meshomatic", "wss://us-east.meshomatic.net:443/mqtt", "us-east.meshomatic.net", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "cascadiamesh", "wss://mqtt-v1.cascadiamesh.org:443/mqtt", "mqtt-v1.cascadiamesh.org", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true, 55, nullptr, nullptr },
{ "tennmesh", "mqtt://mqtt.tennmesh.com:1883", nullptr, nullptr, MQTT_AUTH_USERPASS, MQTT_TOPIC_MESHCORE, 0, true, 55, "mqttfeed", "tc2live" },
};
// Find a preset by name, returns nullptr if not found
+42 -33
View File
@@ -181,8 +181,10 @@ const char* MQTTBridge::wifiReasonStr(uint8_t reason) {
case 2: return "auth expired";
case 4: return "assoc timeout";
case 8: return "AP disconnected";
case 15: return "wrong password";
case 15: return "4-way handshake timeout";
case 34: return "AP state mismatch (class 3 frame)";
case 39: return "SSID not found";
case 63: return "SA query timeout (PMF)";
case 200: return "signal lost";
case 201: return "security mismatch";
case 202: return "auth mode rejected";
@@ -301,7 +303,8 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCCloc
_snmp_agent(nullptr),
#endif
_last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false),
_wifi_disconnected_time(0), _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0)
_wifi_disconnected_time(0), _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0),
_last_slot_reconnect_ms(0)
#ifdef ESP_PLATFORM
, _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), _raw_data_mutex(nullptr),
_mqtt_task_stack(nullptr), _packet_queue_storage(nullptr)
@@ -748,7 +751,16 @@ void MQTTBridge::mqttTaskLoop() {
#endif
unsigned long now = millis();
handleWiFiConnection(now);
bool wifi_just_connected = handleWiFiConnection(now);
if (wifi_just_connected) {
// WiFi recovered — reset last_reconnect_attempt for disconnected slots so they
// retry immediately rather than waiting up to 5 min for backoff timers to expire.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) {
_slots[i].last_reconnect_attempt = 0;
}
}
}
// Check for pending NTP sync (triggered from WiFi event handler)
if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
@@ -1025,6 +1037,9 @@ void MQTTBridge::setupSlot(int index) {
if (slot.auth_token && strlen(slot.auth_token) > 0) {
slot.client->setCredentials(_jwt_username, slot.auth_token);
}
} else if (slot.preset->auth_type == MQTT_AUTH_USERPASS &&
slot.preset->userpass_username && slot.preset->userpass_password) {
slot.client->setCredentials(slot.preset->userpass_username, slot.preset->userpass_password);
}
} else {
// Custom broker slot — build persistent URI
@@ -1194,8 +1209,12 @@ void MQTTBridge::maintainSlotConnections() {
}
// Only allow one reconnect attempt per maintenance cycle to avoid
// multiple simultaneous TLS handshakes blocking the network stack
bool reconnect_attempted_this_cycle = false;
// multiple simultaneous TLS handshakes blocking the network stack.
// Time-based guard: block reconnects if any slot reconnected within the last 15 s,
// ensuring the previous TLS handshake (and its Core-0-expensive completion events)
// finish before the next slot begins its own handshake.
const unsigned long RECONNECT_GUARD_MS = 15000UL;
bool reconnect_attempted_this_cycle = (now_millis - _last_slot_reconnect_ms < RECONNECT_GUARD_MS);
// Only allow one full teardown+setup per cycle to limit heap fragmentation
// when multiple slots fail simultaneously
bool teardown_attempted_this_cycle = false;
@@ -1261,11 +1280,12 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
// to avoid internal heap leak/fragmentation from destroy/create cycles
MQTT_DEBUG_PRINTLN("MQTT%d token renewal: reconnecting with fresh credentials", index + 1);
if (slot.client->connected()) {
slot.client->disconnect();
slot.client->disconnect(); // stops the client internally
}
slot.client->setCredentials(_jwt_username, slot.auth_token);
slot.client->reconnect();
slot.client->connect(); // restart stopped client; reconnect() fails silently on a stopped client
reconnect_attempted = true;
_last_slot_reconnect_ms = now_millis;
} else {
// Token renewed but old one still valid — just update credentials for next reconnect
slot.client->setCredentials(_jwt_username, slot.auth_token);
@@ -1288,6 +1308,7 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
if (probe_elapsed >= CIRCUIT_BREAKER_PROBE_INTERVAL_MS) {
slot.last_reconnect_attempt = now_millis;
reconnect_attempted = true;
_last_slot_reconnect_ms = now_millis;
MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (attempting single reconnect after %lu ms)", index + 1, probe_elapsed);
if (slot_uses_jwt) {
unsigned long current_time = time(nullptr);
@@ -1353,23 +1374,26 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
}
MQTT_DEBUG_PRINTLN("MQTT%d reconnecting (backoff level %d, failures at max: %d)", index + 1, slot.reconnect_backoff, slot.max_backoff_failures);
reconnect_attempted = true;
_last_slot_reconnect_ms = now_millis;
if (slot_uses_jwt) {
unsigned long current_time = time(nullptr);
bool token_still_valid = slot.token_expires_at > 0 &&
current_time < slot.token_expires_at &&
(slot.token_expires_at - current_time) > 120; // >2 min remaining
if (token_still_valid && slot.reconnect_backoff <= 2) {
// Lightweight reconnect — reuse existing client but refresh JWT for fresh iat
if (token_still_valid) {
// Token valid — always lightweight reconnect regardless of backoff level.
// Avoids creating a new TLS session (teardown+setup) which can race with
// WiFi association and cause drops when multiple slots do it simultaneously.
if (createSlotAuthToken(index)) {
slot.client->setCredentials(_jwt_username, slot.auth_token);
MQTT_DEBUG_PRINTLN("MQTT%d lightweight reconnect (fresh token, expires in %lu sec)", index + 1, slot.token_expires_at - (unsigned long)time(nullptr));
MQTT_DEBUG_PRINTLN("MQTT%d reconnect (fresh token, backoff %d)", index + 1, slot.reconnect_backoff);
} else {
MQTT_DEBUG_PRINTLN("MQTT%d lightweight reconnect (token refresh failed, using existing)", index + 1);
MQTT_DEBUG_PRINTLN("MQTT%d reconnect (token refresh failed, backoff %d)", index + 1, slot.reconnect_backoff);
}
slot.client->reconnect();
} else {
// Full teardown: token expired/near expiry, or lightweight reconnect failed (backoff 3+)
// Token expired — full teardown to get fresh TLS context + credentials
if (teardown_attempted) {
// Defer to next cycle to limit heap fragmentation from simultaneous teardowns
slot.last_reconnect_attempt = now_millis;
@@ -1378,7 +1402,7 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
teardown_attempted = true;
uint8_t saved_backoff = slot.reconnect_backoff;
uint8_t saved_failures = slot.max_backoff_failures;
MQTT_DEBUG_PRINTLN("MQTT%d full teardown+setup for reconnect (backoff %d)", index + 1, saved_backoff);
MQTT_DEBUG_PRINTLN("MQTT%d full teardown+setup (token expired, backoff %d)", index + 1, saved_backoff);
teardownSlot(index);
setupSlot(index);
_slots[index].reconnect_backoff = saved_backoff;
@@ -1386,26 +1410,11 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
_slots[index].last_reconnect_attempt = now_millis;
}
} else {
// Non-JWT slots
if (slot.reconnect_backoff <= 2) {
// Lightweight reconnect on existing client
slot.client->reconnect();
} else {
// Full teardown after lightweight reconnect failed (backoff 3+)
if (teardown_attempted) {
slot.last_reconnect_attempt = now_millis;
return;
}
teardown_attempted = true;
uint8_t saved_backoff = slot.reconnect_backoff;
uint8_t saved_failures = slot.max_backoff_failures;
MQTT_DEBUG_PRINTLN("MQTT%d full teardown+setup for reconnect (backoff %d)", index + 1, saved_backoff);
teardownSlot(index);
setupSlot(index);
_slots[index].reconnect_backoff = saved_backoff;
_slots[index].max_backoff_failures = saved_failures;
_slots[index].last_reconnect_attempt = now_millis;
}
// Non-JWT slots — always lightweight reconnect on existing client.
// recreateMqttClientsForFragmentationRecovery() handles teardown when
// memory pressure warrants it, without the timing hazard here.
MQTT_DEBUG_PRINTLN("MQTT%d reconnect (non-JWT, backoff %d)", index + 1, slot.reconnect_backoff);
slot.client->reconnect();
}
}
}
+1
View File
@@ -231,6 +231,7 @@ private:
unsigned long _wifi_disconnected_time; // 0 when connected
unsigned long _last_wifi_reconnect_attempt;
uint8_t _wifi_reconnect_backoff_attempt; // 0..5 → 15s, 30s, 60s, 120s, 300s; reset on connect
unsigned long _last_slot_reconnect_ms; // guards against concurrent TLS handshakes (15 s inter-slot gap)
// Optional pointers for collecting stats internally (set by mesh if available)
mesh::Dispatcher* _dispatcher; // For air times and errors
+2 -2
View File
@@ -303,8 +303,8 @@ build_flags =
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
-D MQTT_DEBUG=1
-D MESH_PACKET_LOGGING=1
-D MESH_DEBUG=1
# -D MESH_PACKET_LOGGING=1
# -D MESH_DEBUG=1
-D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
-D ESP32_CPU_FREQ=160
-D WITH_SNMP=1