diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8e9a5cca..9549dbae 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1222,7 +1222,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch sprintf(reply, "OK - slot %d preset: %s", slot + 1, preset_name); } } else { - strcpy(reply, "Error: valid presets are: analyzer-us, analyzer-eu, meshmapper, meshrank, waev, custom, none"); + strcpy(reply, "Error: valid presets are: analyzer-us, analyzer-eu, meshmapper, meshrank, waev, cascadiamesh, 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 e74bf255..db6d56f6 100644 --- a/src/helpers/MQTTPresets.h +++ b/src/helpers/MQTTPresets.h @@ -22,6 +22,7 @@ struct MQTTPresetDef { MQTTTopicStyle topic_style; 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) }; // Google Trust Services - GTS Root R4 (used by LetsMesh Analyzer) @@ -83,15 +84,16 @@ static const char ISRG_ROOT_X1[] PROGMEM = "-----END CERTIFICATE-----\n"; // Number of built-in presets -static const int MQTT_PRESET_COUNT = 5; +static const int MQTT_PRESET_COUNT = 6; // 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 }, - { "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 }, - { "meshmapper", "wss://mqtt.meshmapper.cc:443/mqtt", "mqtt.meshmapper.cc", ISRG_ROOT_X1, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 0, true }, - { "meshrank", "mqtts://meshrank.net:8883", nullptr, ISRG_ROOT_X1, MQTT_AUTH_NONE, MQTT_TOPIC_MESHRANK, 0, false }, - { "waev", "wss://mqtt.waev.app:443/mqtt", "mqtt.waev.app", GTS_ROOT_R4, MQTT_AUTH_JWT, MQTT_TOPIC_MESHCORE, 3300, false }, + { "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 }, + { "cascadiamesh", "mqtt://cascadiamesh.ddns.net:1883", nullptr, nullptr, MQTT_AUTH_NONE, MQTT_TOPIC_MESHCORE, 0, true, 0 }, }; // 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 50665922..36afcb9f 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -602,6 +602,15 @@ void MQTTBridge::mqttTaskLoop() { syncTimeWithNTP(); } + // Retry NTP every 30s if initial sync failed (slots can't start without valid time) + if (!_ntp_synced && WiFi.status() == WL_CONNECTED) { + static unsigned long last_ntp_retry = 0; + if (now - last_ntp_retry >= 30000) { + last_ntp_retry = now; + syncTimeWithNTP(); + } + } + // Deferred slot setup: wait until NTP is synced so JWT tokens get valid timestamps. // This avoids wasted TLS handshakes that get rejected due to bad token times. if (_ntp_synced && !_slots_setup_done) { @@ -772,6 +781,9 @@ void MQTTBridge::setupSlot(int index) { slot.client = new PsychicMqttClient(); slot.client->setAutoReconnect(false); // We handle reconnect with our own backoff logic + if (slot.preset && slot.preset->keepalive > 0) { + slot.client->setKeepAlive(slot.preset->keepalive); + } optimizeMqttClientConfig(slot.client, slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT); // Callbacks (capture index by value) @@ -930,7 +942,6 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns if (createSlotAuthToken(index)) { MQTT_DEBUG_PRINTLN("Slot %d token renewed", index); - slot.client->setCredentials(_jwt_username, slot.auth_token); const unsigned long DISCONNECT_THRESHOLD = 60; bool old_token_expired_or_imminent = !time_synced || @@ -939,16 +950,15 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns (time_synced && old_token_expires_at >= 1000000000 && current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD)); - if (old_token_expired_or_imminent && slot.client->connected()) { - slot.client->disconnect(); - #ifdef ESP_PLATFORM - vTaskDelay(pdMS_TO_TICKS(1000 + index * 2000)); // stagger reconnects after token renewal - #endif - slot.last_reconnect_attempt = millis(); - slot.client->connect(); - } else if (!slot.client->connected()) { - slot.last_reconnect_attempt = now_millis; - slot.client->connect(); + if (old_token_expired_or_imminent || !slot.client->connected()) { + // Clean teardown+setup ensures fresh TLS state and credentials + MQTT_DEBUG_PRINTLN("Slot %d token renewal: reconnecting with fresh client", index); + teardownSlot(index); + setupSlot(index); + reconnect_attempted = true; + } else { + // Token renewed but old one still valid — just update credentials for next reconnect + slot.client->setCredentials(_jwt_username, slot.auth_token); } } else { MQTT_DEBUG_PRINTLN("Slot %d token renewal failed", index); @@ -969,6 +979,13 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns slot.last_reconnect_attempt = now_millis; reconnect_attempted = true; MQTT_DEBUG_PRINTLN("Slot %d circuit breaker probe (attempting single reconnect after %lu ms)", index, probe_elapsed); + // Refresh JWT token before reconnecting (it may have expired while tripped) + if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { + if (createSlotAuthToken(index)) { + MQTT_DEBUG_PRINTLN("Slot %d token refreshed before circuit breaker probe", index); + slot.client->setCredentials(_jwt_username, slot.auth_token); + } + } slot.client->connect(); // If the connect callback fires and sets slot.connected = true, // the next maintenance cycle will clear the circuit breaker via the @@ -1000,6 +1017,13 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns } MQTT_DEBUG_PRINTLN("Slot %d reconnecting (backoff level %d, failures at max: %d)", index, slot.reconnect_backoff, slot.max_backoff_failures); reconnect_attempted = true; + // Refresh JWT token before reconnecting (it may have expired during backoff) + if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { + if (createSlotAuthToken(index)) { + MQTT_DEBUG_PRINTLN("Slot %d token refreshed before reconnect", index); + slot.client->setCredentials(_jwt_username, slot.auth_token); + } + } slot.client->connect(); } }