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