From 3b46e9eeed66e73459aacdd9313f6ebcac3a141e Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 5 Apr 2026 22:38:56 -0700 Subject: [PATCH] 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. --- MQTT_IMPLEMENTATION.md | 24 +++++----- src/helpers/CommonCLI.cpp | 2 +- src/helpers/MQTTPresets.h | 19 ++++---- src/helpers/bridges/MQTTBridge.cpp | 75 +++++++++++++++++------------- src/helpers/bridges/MQTTBridge.h | 1 + variants/station_g2/platformio.ini | 4 +- 6 files changed, 70 insertions(+), 55 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 0605bff9..2516e260 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -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 ` - 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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 877c51eb..2996a0ef 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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])); diff --git a/src/helpers/MQTTPresets.h b/src/helpers/MQTTPresets.h index e06b3bd8..c0090192 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -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 diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index ca1017ac..612f6f49 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -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(); } } } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 9e836b3f..60c648b5 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -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 diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index a5d813ef..ec285d59 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -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