#include "MQTTBridge.h" #include "../MQTTMessageBuilder.h" #include "../TxtDataHelpers.h" #include #include #include #include #include #include #include #ifdef WITH_SNMP #include "../SNMPAgent.h" #endif #ifdef ESP_PLATFORM #include #include #include #include #include #include #include #endif // Effective MQTT origin: empty mqtt_origin follows node_name; otherwise mqtt_origin override (quotes stripped). static void applyEffectiveOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* dest, size_t dest_size) { if (!np || !obs || !dest || dest_size == 0) return; if (obs->mqtt_origin[0] == '\0') { strncpy(dest, np->node_name, dest_size - 1); } else { strncpy(dest, obs->mqtt_origin, dest_size - 1); } dest[dest_size - 1] = '\0'; StrHelper::stripSurroundingQuotes(dest, dest_size); } static const char* const kNtpBuiltinFallbacks[] = { "pool.ntp.org", "time.google.com", "time.cloudflare.com", "time.aws.com", "time.nist.gov", }; static constexpr size_t kNtpBuiltinFallbackCount = sizeof(kNtpBuiltinFallbacks) / sizeof(kNtpBuiltinFallbacks[0]); static_assert(MQTTBridge::kMaxNtpServers >= 1 + (int)kNtpBuiltinFallbackCount, "kMaxNtpServers must hold the custom primary plus all built-in fallbacks"); static bool ntpHostnameEquals(const char* a, const char* b) { if (!a || !b) return false; return strcasecmp(a, b) == 0; } static void fillNtpServerList(const MQTTPrefs* prefs, const char* servers[], int& count) { count = 0; if (prefs && prefs->mqtt_ntp_server[0] != '\0') { servers[count++] = prefs->mqtt_ntp_server; } for (size_t i = 0; i < kNtpBuiltinFallbackCount && count < MQTTBridge::kMaxNtpServers; i++) { const char* fb = kNtpBuiltinFallbacks[i]; bool dup = false; for (int j = 0; j < count; j++) { if (ntpHostnameEquals(servers[j], fb)) { dup = true; break; } } if (!dup) { servers[count++] = fb; } } } const char* MQTTBridge::effectiveNtpPrimary(const MQTTPrefs* obs) { if (obs && obs->mqtt_ntp_server[0] != '\0') { return obs->mqtt_ntp_server; } return kNtpBuiltinFallbacks[0]; } void MQTTBridge::refreshOriginFromPrefs() { if (!_prefs) return; applyEffectiveOrigin(_prefs, _obs, _origin, sizeof(_origin)); } void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* obs, char* buf, size_t buf_size) { if (!buf || buf_size == 0) return; if (!np || !obs) { buf[0] = '\0'; return; } applyEffectiveOrigin(np, obs, buf, buf_size); } // Helper function to check if WiFi credentials are valid static bool isWiFiConfigValid(const MQTTPrefs* obs) { // Check if WiFi SSID is configured (not empty) if (!obs || strlen(obs->wifi_ssid) == 0) { return false; } // WiFi password can be empty for open networks, so we don't check it return true; } #ifdef WITH_MQTT_BRIDGE // A custom slot endpoint is complete if a port is set, or if the host is a // full URI with a scheme (esp-mqtt applies scheme default ports, and the URI // builder in setupSlot() preserves any embedded port/path). static bool customEndpointComplete(const char* host, uint16_t port) { return host[0] != '\0' && (port != 0 || strstr(host, "://") != nullptr); } bool MQTTBridge::isConfigValid(const MQTTPrefs* obs) { if (!obs || !isWiFiConfigValid(obs)) return false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { const char* preset_name = obs->mqtt_slot_preset[i]; if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue; if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { if (customEndpointComplete(obs->mqtt_slot_host[i], obs->mqtt_slot_port[i])) return true; } else if (findMQTTPreset(preset_name) != nullptr) { return true; } } return false; } // Optional embedded CA bundle symbols produced by board_build.embed_files. // Weak linkage keeps non-bundle builds linkable and allows runtime fallback. extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak)); extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak)); // Track whether the global cert bundle has been loaded into s_crt_bundle. // Loading must happen exactly once to avoid a use-after-free race when multiple // TLS slots are set up in sequence (each connect() launches an async task). static bool s_ca_bundle_loaded = false; // PSRAM-aware allocation: prefer PSRAM on ESP32 when BOARD_HAS_PSRAM, fallback to internal heap or malloc. // Use psram_free() for any pointer returned by psram_malloc(). static void* psram_malloc(size_t size) { if (size == 0) return nullptr; #if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM); if (p != nullptr) return p; p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL); return p; #else return malloc(size); #endif } static void* psram_calloc(size_t n, size_t size) { if (n == 0 || size == 0) return nullptr; #if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) void* p = heap_caps_calloc(n, size, MALLOC_CAP_SPIRAM); if (p != nullptr) return p; return heap_caps_calloc(n, size, MALLOC_CAP_INTERNAL); #else return calloc(n, size); #endif } static void psram_free(void* ptr) { if (ptr == nullptr) return; #if defined(ESP_PLATFORM) heap_caps_free(ptr); #else free(ptr); #endif } // 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; // Last WiFi disconnect reason (from ESP-IDF event). Used for get wifi.status diagnostics. static uint8_t s_wifi_disconnect_reason = 0; static unsigned long s_wifi_disconnect_time = 0; #ifdef MQTT_MEMORY_DEBUG // #region agent log static void agentLogHeap(const char* location, const char* message, const char* hypothesisId, size_t free_h, size_t max_alloc, unsigned long internal_free, unsigned long spiram_free) { char buf[320]; snprintf(buf, sizeof(buf), "{\"sessionId\":\"debug-session\",\"location\":\"%s\",\"message\":\"%s\",\"hypothesisId\":\"%s\"," "\"data\":{\"free\":%u,\"max_alloc\":%u,\"internal_free\":%lu,\"spiram_free\":%lu},\"timestamp\":%lu}", location, message, hypothesisId, (unsigned)free_h, (unsigned)max_alloc, internal_free, spiram_free, (unsigned long)millis()); Serial.println(buf); } // #endregion #endif // Singleton for formatMqttStatusReply (set in begin(), cleared in end()) static MQTTBridge* s_mqtt_bridge_instance = nullptr; unsigned long MQTTBridge::getWifiConnectedAtMillis() { return s_wifi_connected_at; } void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs) { if (buf == nullptr || bufsize == 0) return; const char* msgs = (obs && obs->mqtt_status_enabled) ? "on" : "off"; if (s_mqtt_bridge_instance == nullptr || !s_mqtt_bridge_instance->_initialized) { snprintf(buf, bufsize, "> msgs: %s (bridge not running)", msgs); return; } MQTTBridge* b = s_mqtt_bridge_instance; // Build per-slot status strings (compact format to fit 160-byte reply buffer) // Only show configured slots, skip "none" slots int q = 0; #ifdef ESP_PLATFORM if (b->_packet_queue_handle != nullptr) { q = (int)uxQueueMessagesWaiting(b->_packet_queue_handle); } #else q = b->_queue_count; #endif int pos = snprintf(buf, bufsize, "> msgs: %s", msgs); for (int i = 0; i < RUNTIME_MQTT_SLOTS && pos < (int)bufsize - 1; i++) { const MQTTSlot& slot = b->_slots[i]; const char* name = nullptr; const char* state = nullptr; if (!slot.enabled && slot.preset) { name = slot.preset->name; state = "inactive"; } else if (!slot.enabled) { continue; // Skip unconfigured slots } else if (!b->isSlotReady(i)) { name = slot.preset ? slot.preset->name : "custom"; state = "wait"; } else if (slot.connected) { name = slot.preset ? slot.preset->name : "custom"; state = "ok"; } else if (slot.circuit_breaker_tripped) { name = slot.preset ? slot.preset->name : "custom"; state = "fail"; } else { name = slot.preset ? slot.preset->name : "custom"; state = "disc"; } pos += snprintf(buf + pos, bufsize - pos, ", %d: %s (%s)", i + 1, name, state); } snprintf(buf + pos, bufsize - pos, ", q:%d", q); } uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; } unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; } unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0; return _slots[slot_index].current_outage_started_ms; } bool MQTTBridge::isSlotEnabledAndAttempted(int slot_index) const { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false; const MQTTSlot& s = _slots[slot_index]; return s.enabled && s.initial_connect_done; } const char* MQTTBridge::getSlotPresetName(int slot_index) const { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return "?"; const MQTTSlot& s = _slots[slot_index]; if (s.preset && s.preset->name) return s.preset->name; if (!s.enabled) return MQTT_PRESET_NONE; return MQTT_PRESET_CUSTOM; } const char* MQTTBridge::wifiReasonStr(uint8_t reason) { switch (reason) { case 2: return "auth expired"; case 4: return "assoc timeout"; case 8: return "AP disconnected"; case 15: return "4-way handshake timeout"; case 18: return "group cipher mismatch"; case 40: return "cipher suite rejected"; case 49: return "invalid PMKID"; case 61: return "AP BSS management"; case 88: return "AP BSS management"; case 168: return "AP band-steering kick"; 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"; case 204: return "handshake timeout"; default: return nullptr; } } const char* MQTTBridge::tlsErrorStr(int32_t err) { switch (err) { case 0x8001: return "DNS failed"; case 0x8002: return "socket error"; case 0x8004: return "connect refused"; case 0x8006: return "TLS timeout"; case 0x8008: return "connection timeout"; case 0x800B: return "cert verify failed"; case 0x8010: return "mbedTLS error"; case 0x801A: return "TLS handshake failed"; default: return nullptr; } } void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index) { if (!buf || bufsize == 0) return; if (!s_mqtt_bridge_instance || !s_mqtt_bridge_instance->_initialized) { snprintf(buf, bufsize, "> mqtt%d: bridge not running", slot_index + 1); return; } if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) { snprintf(buf, bufsize, "> invalid slot"); return; } MQTTBridge* b = s_mqtt_bridge_instance; const MQTTSlot& slot = b->_slots[slot_index]; // Determine state string const char* state; if (!slot.enabled && !slot.preset && slot.host[0] == '\0') { snprintf(buf, bufsize, "> mqtt%d: not configured", slot_index + 1); return; } else if (!slot.enabled) { state = "inactive"; } else if (!slot.client) { state = "no client"; } else if (slot.connected) { state = "ok"; } else if (slot.circuit_breaker_tripped) { state = "fail"; } else { state = "disc"; } int pos = snprintf(buf, bufsize, "> mqtt%d: %s", slot_index + 1, state); if (slot.disconnect_count > 0) { pos += snprintf(buf + pos, bufsize - pos, ", dc:%lu", (unsigned long)slot.disconnect_count); if (slot.first_disconnect_time > 0) { unsigned long first_disc_age_sec = (millis() - slot.first_disconnect_time) / 1000; pos += snprintf(buf + pos, bufsize - pos, ", first_disc:%lus", first_disc_age_sec); } } // If connected with no errors, we're done if (slot.connected && slot.last_error_time == 0) { snprintf(buf + pos, bufsize - pos, ", no errors"); return; } // Show last error if we have one if (slot.last_error_time > 0) { // TLS error with human-friendly description if (slot.last_tls_err != 0) { const char* desc = tlsErrorStr(slot.last_tls_err); if (desc) { pos += snprintf(buf + pos, bufsize - pos, ", %s (0x%04X)", desc, (unsigned)slot.last_tls_err); } else { pos += snprintf(buf + pos, bufsize - pos, ", tls:0x%04X", (unsigned)slot.last_tls_err); } } // mbedTLS stack error (shown as negative hex per convention) if (slot.last_tls_stack_err != 0) { pos += snprintf(buf + pos, bufsize - pos, ", mbedtls:-0x%04X", (unsigned)(-slot.last_tls_stack_err)); } // Socket errno if (slot.last_sock_errno != 0) { pos += snprintf(buf + pos, bufsize - pos, ", sock:%d", slot.last_sock_errno); } // Time ago unsigned long ago_sec = (millis() - slot.last_error_time) / 1000; if (ago_sec < 60) { snprintf(buf + pos, bufsize - pos, ", %lus ago", ago_sec); } else if (ago_sec < 3600) { snprintf(buf + pos, bufsize - pos, ", %lum ago", ago_sec / 60); } else { snprintf(buf + pos, bufsize - pos, ", %luh ago", ago_sec / 3600); } } else if (!slot.connected) { snprintf(buf + pos, bufsize - pos, ", no error info"); } } // --------------------------------------------------------------------------- // Constructor // --------------------------------------------------------------------------- MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity) : BridgeBase(prefs, mgr, rtc), _obs(obs), _queue_count(0), _last_status_publish(0), _last_status_retry(0), _status_interval(300000), _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), _ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false), _ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0), // Default to UTC; setRules() will be called from syncTimeWithNTP when a // non-UTC timezone string is configured. Timezone has no default ctor, // so we must pass rules here. _timezone_storage(TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}, TimeChangeRule{"UTC", Last, Sun, Mar, 0, 0}), _timezone(&_timezone_storage), _last_raw_len(0), _last_snr(0), _last_rssi(0), _last_raw_timestamp(0), _identity(identity), _cached_has_connected_slots(false), _last_memory_check(0), _skipped_publishes(0), _last_no_broker_log(0), _queue_disconnected_since(0), _last_config_warning(0), _dispatcher(nullptr), _radio(nullptr), _board(nullptr), _ms(nullptr), #ifdef WITH_SNMP _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), _last_slot_reconnect_ms(0) #ifdef ESP_PLATFORM , _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr), _mqtt_task_stack(nullptr), _packet_queue_storage(nullptr) #else , _queue_head(0), _queue_tail(0) #endif { // Initialize default values strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1); strncpy(_iata, "XXX", sizeof(_iata) - 1); strncpy(_device_id, "DEVICE_ID_PLACEHOLDER", sizeof(_device_id) - 1); strncpy(_firmware_version, "unknown", sizeof(_firmware_version) - 1); strncpy(_board_model, "unknown", sizeof(_board_model) - 1); strncpy(_build_date, "unknown", sizeof(_build_date) - 1); _status_enabled = true; _packets_enabled = true; _raw_enabled = false; _rx_enabled = true; _tx_mode = 0; // Initialize all slots to empty/disabled state for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { memset(&_slots[i], 0, sizeof(MQTTSlot)); _slots[i].enabled = false; _slots[i].client = nullptr; _slots[i].preset = nullptr; // auth_token[0] == '\0' after memset above — no valid token _slots[i].connected = false; _slots[i].initial_connect_done = false; _slots[i].token_expires_at = 0; _slots[i].last_token_renewal = 0; _slots[i].reconnect_backoff = 0; _slots[i].max_backoff_failures = 0; _slots[i].circuit_breaker_tripped = false; _slots[i].last_reconnect_attempt = 0; _slots[i].last_log_time = 0; _slots[i].port = 1883; _slot_reconfigure_pending[i] = false; } // Reset CLI-requested forced NTP sync handshake (bridge object is reused across restarts) _ntp_force_requested = false; _ntp_force_done = false; _ntp_force_result = false; // Reset CLI-requested NTP diagnostic handshake _ntp_diag_requested = false; _ntp_diag_done = false; _ntp_diag_count = 0; // Initialize JWT username _jwt_username[0] = '\0'; // Initialize packet queue (FreeRTOS queue will be created in begin()) #ifdef ESP_PLATFORM // Queue and mutex will be created in begin() #else // Initialize circular buffer for non-ESP32 platforms memset(_packet_queue, 0, sizeof(_packet_queue)); #if defined(BOARD_HAS_PSRAM) for (int i = 0; i < MAX_QUEUE_SIZE; i++) { _packet_queue[i].has_raw_data = false; } #endif #endif // On PSRAM boards, allocate raw radio buffer and JSON char buffers in PSRAM to preserve // internal heap. On non-PSRAM boards these are inline arrays in the class object — // no separate allocation needed. #if defined(BOARD_HAS_PSRAM) _last_raw_data = (uint8_t*)psram_malloc(LAST_RAW_DATA_SIZE); _publish_json_buffer = (char*)psram_malloc(PUBLISH_JSON_BUFFER_SIZE); _status_json_buffer = (char*)psram_malloc(STATUS_JSON_BUFFER_SIZE); #else 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. } // --------------------------------------------------------------------------- // begin() // --------------------------------------------------------------------------- void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge..."); // PSRAM diagnostic - helps debug memory fragmentation on boards with external RAM #ifdef BOARD_HAS_PSRAM { bool psram_available = psramFound(); size_t psram_size = 0; size_t psram_free = 0; if (psram_available) { psram_size = ESP.getPsramSize(); psram_free = ESP.getFreePsram(); } MQTT_DEBUG_PRINTLN("PSRAM: found=%s, size=%u, free=%u", psram_available ? "YES" : "NO", psram_size, psram_free); if (!psram_available) { MQTT_DEBUG_PRINTLN("PSRAM: board has PSRAM flag but psramFound()=false. " "Trying explicit psramInit()..."); bool init_result = psramInit(); MQTT_DEBUG_PRINTLN("PSRAM: psramInit() returned %s", init_result ? "true" : "false"); if (init_result) { psram_size = ESP.getPsramSize(); psram_free = ESP.getFreePsram(); MQTT_DEBUG_PRINTLN("PSRAM: after init - size=%u, free=%u", psram_size, psram_free); } } // Log internal heap for comparison MQTT_DEBUG_PRINTLN("PSRAM: internal_free=%u, internal_max_alloc=%u", heap_caps_get_free_size(MALLOC_CAP_INTERNAL), heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); } #else MQTT_DEBUG_PRINTLN("PSRAM: not configured for this board (no BOARD_HAS_PSRAM)"); #endif // Limit active slots based on available memory. // Each WSS/TLS connection needs ~40KB for mbedTLS buffers. // Without PSRAM, even 3 concurrent connections would exhaust internal heap. // With PSRAM, cap at 5 for safety (6 configurable but 5 active max). #if defined(ESP_PLATFORM) && defined(BOARD_HAS_PSRAM) _max_active_slots = psramFound() ? 5 : 2; #else _max_active_slots = 2; #endif MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); // Check if WiFi credentials are configured first if (!isWiFiConfigValid(_obs)) { MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); return; } refreshOriginFromPrefs(); strncpy(_iata, _obs->mqtt_iata, sizeof(_iata) - 1); _iata[sizeof(_iata) - 1] = '\0'; StrHelper::stripSurroundingQuotes(_iata, sizeof(_iata)); // Convert IATA code to uppercase (IATA codes are conventionally uppercase) for (int i = 0; _iata[i]; i++) { _iata[i] = toupper(_iata[i]); } // Update enabled flags from preferences _status_enabled = _obs->mqtt_status_enabled; _packets_enabled = _obs->mqtt_packets_enabled; _raw_enabled = _obs->mqtt_raw_enabled; _rx_enabled = _obs->mqtt_rx_enabled; _tx_mode = _obs->mqtt_tx_enabled; // 0=off, 1=all, 2=advert // Set status interval to 5 minutes (300000 ms), or use preference if set and valid if (_obs->mqtt_status_interval >= 1000 && _obs->mqtt_status_interval <= 3600000) { _status_interval = _obs->mqtt_status_interval; } else { // Invalid or uninitialized value - fix it in preferences and use default _obs->mqtt_status_interval = 300000; // Fix the preference value _status_interval = 300000; // 5 minutes default } // Check for configuration mismatch: bridge.source=tx but mqtt.tx=off checkConfigurationMismatch(); MQTT_DEBUG_PRINTLN("Config: Origin=%s, IATA=%s, Device=%s", _origin, _iata, _device_id); // Apply slot presets from preferences for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { const char* preset_name = _obs->mqtt_slot_preset[i]; if (preset_name[0] != '\0' && strcmp(preset_name, MQTT_PRESET_NONE) != 0) { if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { // Custom broker: copy host/port/username/password from prefs _slots[i].preset = nullptr; strncpy(_slots[i].host, _obs->mqtt_slot_host[i], sizeof(_slots[i].host) - 1); _slots[i].host[sizeof(_slots[i].host) - 1] = '\0'; if (strlen(_slots[i].host) == 0) { MQTT_DEBUG_PRINTLN("MQTT%d: custom preset has no server configured, disabling", i + 1); _slots[i].enabled = false; continue; } _slots[i].enabled = true; _slots[i].port = _obs->mqtt_slot_port[i]; strncpy(_slots[i].username, _obs->mqtt_slot_username[i], sizeof(_slots[i].username) - 1); _slots[i].username[sizeof(_slots[i].username) - 1] = '\0'; strncpy(_slots[i].password, _obs->mqtt_slot_password[i], sizeof(_slots[i].password) - 1); _slots[i].password[sizeof(_slots[i].password) - 1] = '\0'; strncpy(_slots[i].audience, _obs->mqtt_slot_audience[i], sizeof(_slots[i].audience) - 1); _slots[i].audience[sizeof(_slots[i].audience) - 1] = '\0'; } else { const MQTTPresetDef* preset = findMQTTPreset(preset_name); if (preset) { _slots[i].enabled = true; _slots[i].preset = preset; if (mqttPresetNeedsSlotCredentials(preset)) { strncpy(_slots[i].username, _obs->mqtt_slot_username[i], sizeof(_slots[i].username) - 1); _slots[i].username[sizeof(_slots[i].username) - 1] = '\0'; strncpy(_slots[i].password, _obs->mqtt_slot_password[i], sizeof(_slots[i].password) - 1); _slots[i].password[sizeof(_slots[i].password) - 1] = '\0'; } } else { MQTT_DEBUG_PRINTLN("MQTT%d: unknown preset '%s', disabling", i + 1, preset_name); _slots[i].enabled = false; } } } } // Log slot configuration for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled) { if (_slots[i].preset) { MQTT_DEBUG_PRINTLN("MQTT%d: preset=%s", i + 1, _slots[i].preset->name); } else { MQTT_DEBUG_PRINTLN("MQTT%d: custom=%s:%d", i + 1, _slots[i].host, _slots[i].port); } } else { MQTT_DEBUG_PRINTLN("MQTT%d: none", i + 1); } } #ifdef ESP_PLATFORM // Create FreeRTOS queue; use PSRAM storage when available #ifdef BOARD_HAS_PSRAM _packet_queue_storage = (uint8_t*)psram_malloc(MAX_QUEUE_SIZE * sizeof(QueuedPacket)); if (_packet_queue_storage != nullptr) { _packet_queue_handle = xQueueCreateStatic(MAX_QUEUE_SIZE, sizeof(QueuedPacket), _packet_queue_storage, &_packet_queue_struct); } else { _packet_queue_handle = nullptr; } #else // Non-PSRAM: use inline class-member storage with static queue creation. // Eliminates a separate heap allocation, reducing startup fragmentation. _packet_queue_storage = _packet_queue_inline; _packet_queue_handle = xQueueCreateStatic(MAX_QUEUE_SIZE, sizeof(QueuedPacket), _packet_queue_storage, &_packet_queue_struct); #endif if (_packet_queue_handle == nullptr) { _packet_queue_handle = xQueueCreate(MAX_QUEUE_SIZE, sizeof(QueuedPacket)); } if (_packet_queue_handle == nullptr) { MQTT_DEBUG_PRINTLN("Failed to create packet queue!"); #if defined(BOARD_HAS_PSRAM) psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; return; } // Create FreeRTOS task for MQTT/WiFi processing on Core 0 #ifndef MQTT_TASK_CORE #define MQTT_TASK_CORE 0 #endif #ifndef MQTT_TASK_STACK_SIZE #define MQTT_TASK_STACK_SIZE 8192 #endif #ifndef MQTT_TASK_PRIORITY #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; _mqtt_task_handle = nullptr; BaseType_t create_result = xTaskCreatePinnedToCore( mqttTask, "MQTTBridge", MQTT_TASK_STACK_SIZE, this, MQTT_TASK_PRIORITY, &_mqtt_task_handle, MQTT_TASK_CORE ); 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) psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; return; } MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE); #else // Non-ESP32: Initialize WiFi directly (no task) WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); // 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(); _initialized = true; s_mqtt_bridge_instance = this; MQTT_DEBUG_PRINTLN("MQTT Bridge initialized"); } // --------------------------------------------------------------------------- // end() // --------------------------------------------------------------------------- void MQTTBridge::end() { MQTT_DEBUG_PRINTLN("Stopping MQTT Bridge..."); s_mqtt_bridge_instance = nullptr; #ifdef ESP_PLATFORM // Delete FreeRTOS task first (it will clean up WiFi/MQTT connections) if (_mqtt_task_handle != nullptr) { vTaskDelete(_mqtt_task_handle); _mqtt_task_handle = nullptr; } // Free PSRAM task stack psram_free(_mqtt_task_stack); _mqtt_task_stack = nullptr; // Clean up queued packets from FreeRTOS queue // Packets are value-copied in the queue, so no external pointers to clean up. if (_packet_queue_handle != nullptr) { QueuedPacket queued; while (xQueueReceive(_packet_queue_handle, &queued, 0) == pdTRUE) { _queue_count--; } vQueueDelete(_packet_queue_handle); _packet_queue_handle = nullptr; } #if defined(BOARD_HAS_PSRAM) psram_free(_packet_queue_storage); #endif _packet_queue_storage = nullptr; #else // Clean up queued packet references // Packets are value-copied in the queue, so no external pointers to clean up. for (int i = 0; i < _queue_count; i++) { int index = (_queue_head + i) % MAX_QUEUE_SIZE; memset(&_packet_queue[index], 0, sizeof(QueuedPacket)); } _queue_count = 0; _queue_head = 0; _queue_tail = 0; memset(_packet_queue, 0, sizeof(_packet_queue)); #endif // Disconnect and delete persistent MQTT clients. teardownSlot() intentionally // only disconnects; destruction happens here so the mbedTLS contexts survive // the reconfigure/reconnect hot path. for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { teardownSlot(i); } destroySlotClients(); // Timezone is inline class storage (_timezone_storage) since Phase 3 of // the MQTT memory-defrag work — nothing to delete. _timezone always // points at &_timezone_storage and stays valid for the bridge lifetime. // Free PSRAM-backed buffers (non-PSRAM builds use inline class arrays — no free needed) #if defined(BOARD_HAS_PSRAM) psram_free(_last_raw_data); _last_raw_data = nullptr; psram_free(_publish_json_buffer); _publish_json_buffer = nullptr; psram_free(_status_json_buffer); _status_json_buffer = nullptr; #endif // JSON documents are now StaticJsonDocument inline members — no heap allocation to free. _initialized = false; _slots_setup_done = false; // Reset so deferred setup runs again on next begin() MQTT_DEBUG_PRINTLN("MQTT Bridge stopped"); } // --------------------------------------------------------------------------- // FreeRTOS task entry point // --------------------------------------------------------------------------- #ifdef ESP_PLATFORM void MQTTBridge::mqttTask(void* parameter) { MQTTBridge* bridge = static_cast(parameter); if (bridge) { bridge->mqttTaskLoop(); } // Task should never return, but if it does, delete itself vTaskDelete(nullptr); } void MQTTBridge::initializeWiFiInTask() { MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); // Initialize WiFi WiFi.mode(WIFI_STA); // Enable automatic reconnection - ESP32 will handle reconnection automatically WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); // Set up WiFi event handlers for better diagnostics and immediate disconnection // detection. Register ONCE — the bridge is reused across restarts (e.g. stopped // for `ota check`/`ota update`, or `set mqtt…` reconfigure) and WiFi.onEvent() // never removes prior callbacks, so re-registering leaks handlers and duplicates // every log line. if (!_wifi_event_registered) { WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) { switch(event) { case ARDUINO_EVENT_WIFI_STA_GOT_IP: MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); // Set flag to trigger NTP sync from loop() instead of doing it here if (!_ntp_synced && !_ntp_sync_pending) { _ntp_sync_pending = true; } break; case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: s_wifi_disconnect_reason = info.wifi_sta_disconnected.reason; s_wifi_disconnect_time = millis(); MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason); break; default: break; } }); _wifi_event_registered = true; } // Only (re)start the WiFi association if it isn't already up. end() leaves the // STA link connected, so on a restart (e.g. after `ota check`) calling // WiFi.begin() again forces a needless disconnect/reconnect — which also races // the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers). // When already connected, the deferred slot setup still fires in mqttTaskLoop() // because _ntp_synced persists across end() (only _slots_setup_done is reset). if (WiFi.status() != WL_CONNECTED) { WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); } else if (!_ntp_synced && !_ntp_sync_pending) { _ntp_sync_pending = true; // already connected but never synced — kick NTP now } // NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop(). // JWT-auth slots need valid timestamps for token creation, and connecting // before NTP sync just wastes heap on TLS handshakes that will be rejected. MQTT_DEBUG_PRINTLN("WiFi initialization started in task"); } // --------------------------------------------------------------------------- // mqttTaskLoop() - main loop running on Core 0 // --------------------------------------------------------------------------- void MQTTBridge::mqttTaskLoop() { // Initialize WiFi first initializeWiFiInTask(); // Wait a bit for WiFi to start connecting vTaskDelay(pdMS_TO_TICKS(1000)); // Main task loop #ifdef MQTT_MEMORY_DEBUG static unsigned long last_agent_log = 0; #endif while (true) { #ifdef MQTT_MEMORY_DEBUG // #region agent log unsigned long now_loop = millis(); if (now_loop - last_agent_log >= 60000) { last_agent_log = now_loop; size_t free_h = ESP.getFreeHeap(); size_t max_alloc = ESP.getMaxAllocHeap(); unsigned long internal_f = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); unsigned long spiram_f = 0; #ifdef BOARD_HAS_PSRAM spiram_f = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); #endif agentLogHeap("MQTTBridge.cpp:mqttTaskLoop", "mqtt_loop_60s", "H5", free_h, max_alloc, internal_f, spiram_f); } // #endregion #endif unsigned long now = millis(); 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) { _ntp_sync_pending = false; 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(); } } // Process a CLI-requested forced NTP sync (queued from Core 1). Running it here // keeps all NTP I/O on Core 0; requestForcedNtpSync() blocks the CLI thread until // we publish the outcome below. if (_ntp_force_requested) { _ntp_force_requested = false; // primary_only: validate just the server that was set, so a typo fails fast. bool ok = syncTimeWithNTP(true, /*primary_only=*/true); _ntp_force_result = ok; _ntp_force_done = true; // set last so the waiter sees a consistent result } // Process a CLI-requested NTP connectivity diagnostic (queued from Core 1). // Probe-only — never touches the system clock. if (_ntp_diag_requested) { _ntp_diag_requested = false; runNtpDiagProbe(); _ntp_diag_done = true; // set last so the waiter sees populated results } // 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) { _slots_setup_done = true; // Redirect mbedTLS allocations to PSRAM to save ~40KB internal heap per TLS connection. // This is critical when running 3 concurrent WSS connections. #if defined(BOARD_HAS_PSRAM) mbedtls_platform_set_calloc_free(psram_calloc, psram_free); MQTT_DEBUG_PRINTLN("mbedTLS allocator redirected to PSRAM"); #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); _slots[i].enabled = false; // Disable so other loops skip it continue; } char reason[80]; if (!isSlotReady(i, reason, sizeof(reason))) { MQTT_DEBUG_PRINTLN("MQTT%d not ready — run '%s' to connect", i + 1, reason); continue; } setupSlot(i); active_count++; // Stagger connections: 5s between slots to avoid simultaneous TLS handshakes // which compete for ~40KB internal heap each if (i < RUNTIME_MQTT_SLOTS - 1) { vTaskDelay(pdMS_TO_TICKS(5000)); } } } } // Process pending slot reconfigures (queued from CLI on Core 1) for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slot_reconfigure_pending[i]) { _slot_reconfigure_pending[i] = false; MQTT_DEBUG_PRINTLN("Applying deferred reconfigure for MQTT%d (preset: %s)", i + 1, _obs->mqtt_slot_preset[i]); applySlotPreset(i, _obs->mqtt_slot_preset[i]); } } // Maintain slot connections (token renewal, reconnect with backoff) maintainSlotConnections(); // Process packet queue processPacketQueue(); #ifdef WITH_SNMP // SNMP agent loop — process incoming UDP requests if (_snmp_agent) { if (!_snmp_agent->isRunning() && WiFi.isConnected() && _obs->snmp_enabled) { _snmp_agent->begin(_obs->snmp_community); MQTT_DEBUG_PRINTLN("SNMP agent started on port 161 (community: %s)", _obs->snmp_community); } if (_snmp_agent->isRunning()) { // Update MQTT stats from this core int connected = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].connected) connected++; } _snmp_agent->updateMQTTStats(connected, _queue_count, _skipped_publishes); _snmp_agent->loop(); } } #endif // Periodic configuration check (throttled to avoid spam) checkConfigurationMismatch(); // Periodic NTP refresh (every hour) — lightweight, non-blocking. // Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0 // for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops. if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) { refreshNTP(); } // Publish status updates (handle millis() overflow correctly) if (_status_enabled) { bool has_destinations = _cached_has_connected_slots; // Early exit if no destinations - skip all the expensive logic below if (!has_destinations) { if (_last_status_retry != 0) { _last_status_retry = 0; } } else { bool should_publish = false; // First, check if we need to respect retry interval (prevents spam when publish keeps failing) if (_last_status_retry != 0) { unsigned long retry_elapsed = (now >= _last_status_retry) ? (now - _last_status_retry) : (ULONG_MAX - _last_status_retry + now + 1); if (retry_elapsed < STATUS_RETRY_INTERVAL) { should_publish = false; } else { should_publish = true; } } else { if (_last_status_publish == 0) { should_publish = true; } else { unsigned long elapsed = (now >= _last_status_publish) ? (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); should_publish = (elapsed >= _status_interval); } } if (should_publish) { if (_last_status_publish != 0) { unsigned long elapsed = (now >= _last_status_publish) ? (now - _last_status_publish) : (ULONG_MAX - _last_status_publish + now + 1); MQTT_DEBUG_PRINTLN("Status publish timer expired (elapsed: %lu ms, interval: %lu ms)", elapsed, _status_interval); } else { MQTT_DEBUG_PRINTLN("Status publish attempt (first publish or retry)"); } _last_status_retry = now; if (publishStatus()) { _last_status_publish = now; _last_status_retry = 0; MQTT_DEBUG_PRINTLN("Status published successfully, next publish in %lu ms", _status_interval); } else { MQTT_DEBUG_PRINTLN("Status publish failed, will retry in %lu ms", STATUS_RETRY_INTERVAL); } } } } // Update cached connection status periodically (every 5 seconds) // This ensures cache stays accurate even if callbacks miss updates static unsigned long last_slot_status_update = 0; if (now - last_slot_status_update > 5000) { updateCachedConnectionStatus(); last_slot_status_update = now; } // Adaptive delay: 5 ms when packets are queued, 50 ms when idle. // The previous "status approaching" check (widening to 5 ms for 10 s before each status // publish) caused 2 000 unnecessary wakeups per interval; the 50 ms idle tick catches // the status deadline with at most 50 ms of extra latency, which is irrelevant at a // 5-minute interval. vTaskDelay(pdMS_TO_TICKS(_queue_count > 0 ? 5 : 50)); } } #endif // --------------------------------------------------------------------------- // 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; 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(); publishStatusToSlot(index); }); 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); } }); } } 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(); } #ifdef ESP_PLATFORM vTaskDelay(pdMS_TO_TICKS(50)); #else delay(50); #endif delete slot.client; slot.client = nullptr; } } void MQTTBridge::setupSlot(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[index]; if (!slot.enabled) { teardownSlot(index); return; } // 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; } // Reconfigure path: if we're re-applying (e.g. after a preset change), stop // the existing connection cleanly first. The client object (and its mbedTLS // context) is reused; setCredentials / setServer below overwrite the config // fields in place before connect() restarts the ESP-IDF client. if (slot.initial_connect_done) { if (slot.client->connected()) { slot.client->disconnect(); } // Clear TLS verification fields so a stale CA-bundle attach or cert // pointer from a prior preset doesn't override the new one. esp_mqtt_client_config_t* cfg = slot.client->getMqttConfig(); #if ESP_IDF_VERSION_MAJOR == 5 cfg->broker.verification.certificate = nullptr; cfg->broker.verification.certificate_len = 0; cfg->broker.verification.crt_bundle_attach = nullptr; cfg->credentials.username = nullptr; cfg->credentials.authentication.password = nullptr; #else cfg->cert_pem = nullptr; cfg->cert_len = 0; cfg->crt_bundle_attach = nullptr; cfg->username = nullptr; cfg->password = nullptr; #endif slot.auth_token[0] = '\0'; slot.connected = false; slot.token_expires_at = 0; slot.last_token_renewal = 0; slot.reconnect_backoff = 0; slot.max_backoff_failures = 0; slot.circuit_breaker_tripped = false; slot.last_reconnect_attempt = 0; } bool uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || slot.audience[0] != '\0'; optimizeMqttClientConfig(slot.client, uses_jwt); // sets keepalive (45s PSRAM, 75s non-PSRAM) #ifndef MQTT_FORCE_KEEPALIVE_45 #if defined(BOARD_HAS_PSRAM) if (slot.preset && slot.preset->keepalive > 0) { slot.client->setKeepAlive(slot.preset->keepalive); // preset overrides default } #else // Non-PSRAM: keep the longer 75s default to reduce TLS churn. // Preset keepalive (55s) is more aggressive than needed behind Cloudflare. #endif #endif if (slot.preset) { // Preset-based slot slot.client->setServer(slot.preset->server_url); if (slot.preset->ca_cert) { slot.client->setCACert(slot.preset->ca_cert); } // Try to create token and connect (will succeed only if NTP synced) if (slot.preset->auth_type == MQTT_AUTH_JWT) { createSlotAuthToken(index); if (slot.auth_token[0] != '\0') { slot.client->setCredentials(_jwt_username, slot.auth_token); } } else if (slot.preset->auth_type == MQTT_AUTH_USERPASS) { if (slot.preset->userpass_username && slot.preset->userpass_password) { slot.client->setCredentials(slot.preset->userpass_username, slot.preset->userpass_password); } else if (strlen(slot.username) > 0) { slot.client->setCredentials(slot.username, slot.password); } } } else { // Custom broker slot — build persistent URI // If host already has a scheme (mqtt://, mqtts://, ws://, wss://), preserve the full URI // (including optional path/query) and only inject :port when the authority has no explicit port. // Otherwise, infer protocol from port number. bool has_scheme = (strncmp(slot.host, "mqtt://", 7) == 0 || strncmp(slot.host, "mqtts://", 8) == 0 || strncmp(slot.host, "ws://", 5) == 0 || strncmp(slot.host, "wss://", 6) == 0); if (has_scheme) { const char* authority = strstr(slot.host, "://"); authority = authority ? authority + 3 : slot.host; const char* path = strchr(authority, '/'); const char* authority_end = path ? path : slot.host + strlen(slot.host); bool has_explicit_port = false; // Detect host:port in URI authority (IPv6 literals in [addr]:port are supported). if (authority < authority_end) { if (*authority == '[') { const char* close = (const char*)memchr(authority, ']', authority_end - authority); if (close && (close + 1) < authority_end && *(close + 1) == ':') { has_explicit_port = true; } } else { const char* colon = (const char*)memchr(authority, ':', authority_end - authority); if (colon != nullptr) { has_explicit_port = true; } } } if (has_explicit_port || slot.port == 0) { snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s", slot.host); } else { const size_t authority_len = (size_t)(authority_end - slot.host); snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%.*s:%u%s", (int)authority_len, slot.host, (unsigned)slot.port, path ? path : ""); } } else { const char* proto = "mqtt"; if (slot.port == 8883) { proto = "mqtts"; } else if (slot.port == 443) { proto = "wss"; } snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s://%s:%d", proto, slot.host, slot.port); } slot.client->setServer(slot.broker_uri); MQTT_DEBUG_PRINTLN("MQTT%d custom broker URI: %s (host='%s', port=%u)", index + 1, slot.broker_uri, slot.host, (unsigned)slot.port); // Custom TLS/WSS slots need a CA bundle for server verification. // The bundle is loaded into the global s_crt_bundle exactly once to avoid // a use-after-free race: connect() launches an async FreeRTOS task, and // calling setCACertBundle() again from a later slot would free the global // crts array while a prior slot's TLS handshake may still be reading it. bool needs_tls = (strncmp(slot.broker_uri, "mqtts://", 8) == 0 || strncmp(slot.broker_uri, "wss://", 6) == 0); if (needs_tls) { if (!s_ca_bundle_loaded) { size_t bundle_len = 0; if (rootca_crt_bundle_start != nullptr && rootca_crt_bundle_end != nullptr && rootca_crt_bundle_end > rootca_crt_bundle_start) { bundle_len = static_cast(rootca_crt_bundle_end - rootca_crt_bundle_start); } if (bundle_len > 0) { MQTT_DEBUG_PRINTLN("MQTT global CA bundle init: embedded bundle (%u bytes)", (unsigned)bundle_len); // Load the bundle into the global s_crt_bundle via the first client. // This is a one-time operation; subsequent clients reuse via attachArduinoCACertBundle. slot.client->setCACertBundle(rootca_crt_bundle_start, bundle_len); s_ca_bundle_loaded = true; } else { MQTT_DEBUG_PRINTLN("MQTT%d TLS: no embedded cert bundle available", index + 1); } } else { // Global bundle already loaded — just attach the callback for this client. slot.client->attachArduinoCACertBundle(true); } MQTT_DEBUG_PRINTLN("MQTT%d TLS verify: CA bundle %s", index + 1, s_ca_bundle_loaded ? "active" : "unavailable"); } else { MQTT_DEBUG_PRINTLN("MQTT%d custom broker uses non-TLS transport", index + 1); } // 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); } 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); } } slot.client->connect(); slot.initial_connect_done = true; } // Disconnect the slot's MQTT client and clear per-connection state, but leave // the client object alive so a subsequent setupSlot() can reuse its mbedTLS // context. This is called both on reconfigure (preset change) and at shutdown; // destruction of the underlying client happens once in destroySlotClients(). void MQTTBridge::teardownSlot(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[index]; if (slot.client && slot.client->connected()) { slot.client->disconnect(); #ifdef ESP_PLATFORM vTaskDelay(pdMS_TO_TICKS(50)); #else delay(50); #endif } slot.auth_token[0] = '\0'; slot.connected = false; slot.initial_connect_done = false; slot.broker_uri[0] = '\0'; slot.token_expires_at = 0; slot.last_token_renewal = 0; slot.reconnect_backoff = 0; slot.max_backoff_failures = 0; slot.circuit_breaker_tripped = false; slot.last_reconnect_attempt = 0; slot.last_log_time = 0; slot.last_deferred_log_ms = 0; } void MQTTBridge::maintainSlotConnections() { if (!_identity) return; // Check WiFi status first if (WiFi.status() != WL_CONNECTED) return; unsigned long now_millis = millis(); unsigned long current_time = time(nullptr); bool time_synced = (current_time >= 1000000000); // After year 2001 // JWT tokens require valid timestamps unsigned long clock_sec = current_time; bool clock_looks_set = (clock_sec >= 1735689600); // 2025-01-01 00:00:00 UTC bool can_do_jwt = _ntp_synced || clock_looks_set; // Count connected slots to inform reconnect decisions int connected_count = 0; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].connected) connected_count++; } // Only allow one reconnect attempt per maintenance cycle to avoid // 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; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (!_slots[i].enabled || !_slots[i].client) 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) || (!_slots[i].preset && _slots[i].audience[0] != '\0'); if (slot_jwt && !can_do_jwt) { continue; } maintainSlotConnection(i, now_millis, current_time, time_synced, reconnect_attempted_this_cycle, teardown_attempted_this_cycle); } } void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted) { MQTTSlot& slot = _slots[index]; // Forgive past failures only after the connection has proven stable. // 2 minutes covers at least one keepalive round-trip (keepalive is 75 s), // so a link that can't survive a single keepalive period never resets the // 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. static const unsigned long BACKOFF_STABLE_RESET_MS = 120000UL; if (slot.connected && (slot.reconnect_backoff != 0 || slot.max_backoff_failures != 0) && slot.connected_at_ms != 0 && (now_millis - slot.connected_at_ms) >= BACKOFF_STABLE_RESET_MS) { MQTT_DEBUG_PRINTLN("MQTT%d stable for %lus - clearing reconnect backoff (was level %d)", index + 1, (now_millis - slot.connected_at_ms) / 1000UL, slot.reconnect_backoff); slot.reconnect_backoff = 0; slot.max_backoff_failures = 0; } // JWT token renewal (for preset JWT slots and custom slots with audience set) bool slot_uses_jwt = (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) || (!slot.preset && slot.audience[0] != '\0'); if (slot_uses_jwt) { // Renew (and below, reconnect) this many seconds before the token's exp // claim. Scaled to the slot's token lifetime — see tokenRenewalBufferSecs // for why a flat 60 s lost the renewal race against brokers that enforce // exp on live sessions (waev's 55-minute tokens). const unsigned long renewal_buffer = tokenRenewalBufferSecs(slotTokenLifetime(index)); bool token_needs_renewal = false; if (!time_synced) { token_needs_renewal = (slot.token_expires_at == 0); } else { token_needs_renewal = (slot.token_expires_at == 0) || !(slot.token_expires_at >= 1000000000) || (current_time >= slot.token_expires_at) || (current_time >= (slot.token_expires_at - renewal_buffer)); } // Throttle renewal attempts to once per minute const unsigned long RENEWAL_THROTTLE_MS = 60000; bool can_attempt_renewal = (now_millis - slot.last_token_renewal) >= RENEWAL_THROTTLE_MS; if (token_needs_renewal && can_attempt_renewal) { slot.last_token_renewal = now_millis; unsigned long old_token_expires_at = slot.token_expires_at; if (createSlotAuthToken(index)) { MQTT_DEBUG_PRINTLN("MQTT%d token renewed", index + 1); // Bounce the connection while WE control the timing whenever the old // token is inside the renewal buffer — waiting for the broker to // enforce exp mid-session means a FIN plus a trip through the backoff // ladder instead of one clean reconnect. Same buffer as the renewal // trigger above, so a renewal implies a proactive reconnect. bool old_token_expired_or_imminent = !time_synced || (old_token_expires_at == 0) || (current_time >= old_token_expires_at) || (time_synced && old_token_expires_at >= 1000000000 && current_time >= (old_token_expires_at - renewal_buffer)); if (old_token_expired_or_imminent || !slot.client->connected()) { // Disconnect + reconnect with fresh credentials, reusing existing client // 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(); // stops the client internally } slot.client->setCredentials(_jwt_username, slot.auth_token); slot.client->connect(); // restart stopped client; reconnect() fails silently on a stopped client reconnect_attempted = true; _last_slot_reconnect_ms = now_millis; MQTT_DEBUG_PRINTLN("MQTT%d int_heap=%d at token renewal reconnect", index + 1, (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); MQTT_DEBUG_PRINTLN(" radio_state=%d, last_rx=%lums ago", _radio ? _radio->getRadioState() : -1, (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0); } 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("MQTT%d token renewal failed", index + 1); slot.token_expires_at = 0; } return; // Token renewal handled connect; skip backoff logic below } } // Phase 4 (MQTT memory-defrag): the MIN_TLS_HEAP preflight was a workaround // for the fragmentation caused by per-reconnect mbedTLS allocations. With // persistent clients (Phase 1), the mbedTLS context is allocated once at // startup and the preflight is no longer necessary. // Periodic probe for circuit-breaker-tripped slots (recovery from transient outages) // Attempts a single reconnect every 30 minutes to see if the server has come back if (slot.circuit_breaker_tripped && !reconnect_attempted) { static const unsigned long CIRCUIT_BREAKER_PROBE_INTERVAL_MS = 1800000UL; // 30 minutes unsigned long probe_elapsed = (now_millis >= slot.last_reconnect_attempt) ? (now_millis - slot.last_reconnect_attempt) : (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); 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, int_heap=%d)", index + 1, probe_elapsed, (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); MQTT_DEBUG_PRINTLN(" radio_state=%d, last_rx=%lums ago", _radio ? _radio->getRadioState() : -1, (_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. if (createSlotAuthToken(index)) { slot.client->setCredentials(_jwt_username, slot.auth_token); MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker probe (fresh token)", index + 1); } slot.client->reconnect(); } else { slot.client->reconnect(); } // If the connect callback fires and sets slot.connected = true, // it will clear circuit_breaker_tripped via the onConnect handler } } // Reconnect with exponential backoff (for disconnected slots that already have valid config) // Only one reconnect per maintenance cycle to prevent TLS handshakes from blocking other slots if (!slot.connected && slot.initial_connect_done && !slot.circuit_breaker_tripped && !reconnect_attempted) { static const unsigned long SLOT_BACKOFF_MS[] = { 10000, 30000, 60000, 120000, 300000 }; static const uint8_t MAX_FAILURES_AT_MAX_BACKOFF = 3; // ~15 min at max backoff before giving up unsigned long reconnect_elapsed = (now_millis >= slot.last_reconnect_attempt) ? (now_millis - slot.last_reconnect_attempt) : (ULONG_MAX - slot.last_reconnect_attempt + now_millis + 1); unsigned int idx = (slot.reconnect_backoff < 5) ? slot.reconnect_backoff : 4; unsigned long delay_ms = SLOT_BACKOFF_MS[idx] + (index * 3000UL); // stagger by slot index if (reconnect_elapsed >= delay_ms) { slot.last_reconnect_attempt = now_millis; if (slot.reconnect_backoff < 5) { slot.reconnect_backoff++; } else { slot.max_backoff_failures++; if (slot.max_backoff_failures >= MAX_FAILURES_AT_MAX_BACKOFF) { slot.circuit_breaker_tripped = true; MQTT_DEBUG_PRINTLN("MQTT%d circuit breaker tripped after %d failures at max backoff - stopping reconnect attempts. Reconfigure slot to retry.", index + 1, slot.max_backoff_failures); return; } } MQTT_DEBUG_PRINTLN("MQTT%d reconnecting (backoff level %d, failures at max: %d, int_heap=%d)", index + 1, slot.reconnect_backoff, slot.max_backoff_failures, (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); MQTT_DEBUG_PRINTLN(" radio_state=%d, last_rx=%lums ago", _radio ? _radio->getRadioState() : -1, (_radio && _radio->getLastRecvMillis() > 0) ? (_ms->getMillis() - _radio->getLastRecvMillis()) : 0); reconnect_attempted = true; _last_slot_reconnect_ms = now_millis; if (slot_uses_jwt) { // Always lightweight reconnect on the persistent client. A stale/expired // token is handled by regenerating it in place and updating credentials // — no teardown is needed because the client and its mbedTLS context // persist for the bridge lifetime. if (createSlotAuthToken(index)) { slot.client->setCredentials(_jwt_username, slot.auth_token); MQTT_DEBUG_PRINTLN("MQTT%d reconnect (fresh token, backoff %d)", index + 1, slot.reconnect_backoff); } else { MQTT_DEBUG_PRINTLN("MQTT%d reconnect (token refresh failed, backoff %d)", index + 1, slot.reconnect_backoff); } slot.client->reconnect(); } else { // Non-JWT slots — lightweight reconnect on existing client. MQTT_DEBUG_PRINTLN("MQTT%d reconnect (non-JWT, backoff %d)", index + 1, slot.reconnect_backoff); slot.client->reconnect(); } } } } // Effective JWT lifetime for a slot: the preset's token_lifetime (or the 24 h // default for custom/audience slots), minus the per-slot expiry stagger that // keeps multiple JWT slots from renewing/reconnecting simultaneously. This is // the exact value createSlotAuthToken() puts in the token's exp claim, so the // renewal scheduling in maintainSlotConnection() can be derived from it. unsigned long MQTTBridge::slotTokenLifetime(int index) const { const MQTTSlot& slot = _slots[index]; unsigned long base_lifetime = 86400; // default 24h if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT && slot.preset->token_lifetime > 0) { base_lifetime = slot.preset->token_lifetime; } // Stagger token expiry per slot to avoid simultaneous renewal/reconnect. // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced. unsigned long stagger = index * min((unsigned long)300, base_lifetime / 20); return base_lifetime - stagger; } // How early (seconds before the token's exp claim) to renew the token AND // proactively bounce the connection with fresh credentials. exp and the // renewal schedule are locked together (both derive from slotTokenLifetime), // so this buffer is the ONLY margin between "device re-authenticates" and // "broker enforces exp and FIN-closes the session mid-stream" — shortening a // preset's token_lifetime moves both times together and cannot widen it. // The old flat 60 s lost that race whenever the device clock ran slow, or a // single renewal attempt failed (the 60 s renewal throttle then ate the whole // margin) — observed on the waev preset, whose 55-minute tokens are the only // ones short enough for brokers to enforce exp against a live session. // lifetime/10 with a 60 s floor and 300 s cap: 24 h tokens renew 5 min early // (unchanged in practice), waev renews ~5 min early with ~5 throttled retry // windows, and degenerate short lifetimes still renew inside their validity. unsigned long MQTTBridge::tokenRenewalBufferSecs(unsigned long lifetime_secs) { unsigned long buffer = lifetime_secs / 10; if (buffer < 60) buffer = 60; if (buffer > 300) buffer = 300; return buffer; } bool MQTTBridge::createSlotAuthToken(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; if (!_identity) return false; // Determine JWT audience: preset takes priority, then custom slot audience field const char* audience = nullptr; if (slot.preset && slot.preset->auth_type == MQTT_AUTH_JWT) { audience = slot.preset->jwt_audience; } else if (slot.audience[0] != '\0') { audience = slot.audience; } if (!audience || audience[0] == '\0') return false; // Ensure JWT username is set if (_jwt_username[0] == '\0') { char public_key_hex[65]; mesh::Utils::toHex(public_key_hex, _identity->pub_key, PUB_KEY_SIZE); snprintf(_jwt_username, sizeof(_jwt_username), "v1_%s", public_key_hex); } // Prepare owner key const char* owner_key = nullptr; char owner_key_uppercase[65]; if (_obs->mqtt_owner_public_key[0] != '\0') { strncpy(owner_key_uppercase, _obs->mqtt_owner_public_key, sizeof(owner_key_uppercase) - 1); owner_key_uppercase[sizeof(owner_key_uppercase) - 1] = '\0'; for (int i = 0; owner_key_uppercase[i]; i++) { owner_key_uppercase[i] = toupper(owner_key_uppercase[i]); } owner_key = owner_key_uppercase; } char client_version[64]; getClientVersion(client_version, sizeof(client_version)); const char* email = (_obs->mqtt_email[0] != '\0') ? _obs->mqtt_email : nullptr; unsigned long current_time = time(nullptr); unsigned long expires_in = slotTokenLifetime(index); // preset/default lifetime minus per-slot stagger bool time_synced = (current_time >= 1000000000); if (JWTHelper::createAuthToken( *_identity, audience, 0, expires_in, slot.auth_token, AUTH_TOKEN_SIZE, owner_key, client_version, email)) { slot.token_expires_at = time_synced ? (current_time + expires_in) : 0; return true; } slot.token_expires_at = 0; return false; } bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload, bool retained, uint8_t qos) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; MQTTSlot& slot = _slots[index]; if (!slot.client || !slot.connected) { unsigned long now = millis(); if (now - slot.last_log_time > SLOT_LOG_INTERVAL) { slot.last_log_time = now; MQTT_DEBUG_PRINTLN("MQTT%d not connected - skipping publish", index + 1); } return false; } // QoS 0 for the high-rate packet/raw publish paths: no PUBACK, no outbox store, // no per-message heap alloc — critical for non-PSRAM fragmentation. QoS 1 is used // only for low-rate retained status messages where delivery matters. // // esp_mqtt_client_enqueue return convention: QoS 0 returns msg_id == 0 on success // (no tracking since there's no PUBACK); QoS 1/2 return a positive msg_id. Negative // values (-1 generic failure, -2 outbox full) are the only actual failures. int result = slot.client->publish(topic, qos, retained, payload, strlen(payload), true); 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. if (qos > 0) { static unsigned long last_fail_log = 0; unsigned long now = millis(); if (now - last_fail_log > 60000) { MQTT_DEBUG_PRINTLN("MQTT%d publish failed (result=%d qos=%u)", index + 1, result, (unsigned)qos); last_fail_log = now; } } return false; } return true; } bool MQTTBridge::publishToAllSlots(const char* topic, const char* payload, 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)) { published = true; } } } return published; } // --------------------------------------------------------------------------- // Topic building - resolves the correct topic for a given slot and message type. // Presets use hardcoded topic logic; custom slots support user-defined templates. // --------------------------------------------------------------------------- bool MQTTBridge::substituteTopicTemplate(const char* tmpl, MQTTMessageType type, int slot_index, char* buf, size_t buf_size) { const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; const char* token = _obs->mqtt_slot_token[slot_index]; size_t out = 0; const char* p = tmpl; while (*p && out < buf_size - 1) { if (*p == '{') { if (strncmp(p, "{iata}", 6) == 0) { size_t len = strlen(_iata); if (out + len >= buf_size) return false; memcpy(buf + out, _iata, len); out += len; p += 6; } else if (strncmp(p, "{device}", 8) == 0) { size_t len = strlen(_device_id); if (out + len >= buf_size) return false; memcpy(buf + out, _device_id, len); out += len; p += 8; } else if (strncmp(p, "{token}", 7) == 0) { size_t len = strlen(token); if (out + len >= buf_size) return false; memcpy(buf + out, token, len); out += len; p += 7; } else if (strncmp(p, "{type}", 6) == 0) { size_t len = strlen(type_str); if (out + len >= buf_size) return false; memcpy(buf + out, type_str, len); out += len; p += 6; } else { buf[out++] = *p++; } } else { buf[out++] = *p++; } } buf[out] = '\0'; return out > 0; } bool MQTTBridge::buildTopicForSlot(int index, MQTTMessageType type, char* topic_buf, size_t buf_size) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; const MQTTSlot& slot = _slots[index]; // Preset slots: use hardcoded topic logic if (slot.preset) { if (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) { // MeshRank: packets only, uses per-slot token in topic path if (type != MSG_PACKETS) return false; const char* token = _obs->mqtt_slot_token[index]; if (!token || token[0] == '\0') return false; snprintf(topic_buf, buf_size, "meshrank/uplink/%s/%s/packets", token, _device_id); return true; } // MQTT_TOPIC_MESHCORE (default for all other presets) if (!isIATAValid()) return false; const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, type_str); return true; } // Custom slots: use topic template if set, otherwise default meshcore format if (_obs->mqtt_slot_topic[index][0] != '\0') { return substituteTopicTemplate(_obs->mqtt_slot_topic[index], type, index, topic_buf, buf_size); } // Default: meshcore format if (!isIATAValid()) return false; const char* type_str = (type == MSG_STATUS) ? "status" : (type == MSG_PACKETS) ? "packets" : "raw"; snprintf(topic_buf, buf_size, "meshcore/%s/%s/%s", _iata, _device_id, type_str); return true; } void MQTTBridge::publishStatusToSlot(int index) { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[index]; if (!slot.client || !slot.connected) return; refreshOriginFromPrefs(); // Build per-slot topic (handles IATA check for meshcore, token check for meshrank) char status_topic[128]; if (!buildTopicForSlot(index, MSG_STATUS, status_topic, sizeof(status_topic))) { return; // Slot doesn't support status (e.g., meshrank) or missing required config } // 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. #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; #else char* json_buffer = _status_json_buffer; #endif char origin_id[65]; char timestamp[40]; char radio_info[64]; // Status timestamp: UTC with explicit +00:00 offset, same as packet/raw JSON // `timestamp` (system clock is UTC — SNTP offset 0; prefs Timezone is separate). struct timeval now_tv; gettimeofday(&now_tv, nullptr); MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp)); snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", _prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr); strncpy(origin_id, _device_id, sizeof(origin_id) - 1); origin_id[sizeof(origin_id) - 1] = '\0'; char client_version[64]; getClientVersion(client_version, sizeof(client_version)); // Collect stats on-demand if sources are available int battery_mv = -1; int uptime_secs = -1; int errors = -1; int noise_floor = -999; int tx_air_secs = -1; int rx_air_secs = -1; int recv_errors = -1; int packets_sent = -1; int packets_received = -1; if (_board) battery_mv = _board->getBattMilliVolts(); if (_ms) uptime_secs = _ms->getMillis() / 1000; if (_dispatcher) { errors = _dispatcher->getErrFlags(); tx_air_secs = _dispatcher->getTotalAirTime() / 1000; rx_air_secs = _dispatcher->getReceiveAirTime() / 1000; packets_sent = (int)(_dispatcher->getNumSentFlood() + _dispatcher->getNumSentDirect()); packets_received = (int)(_dispatcher->getNumRecvFlood() + _dispatcher->getNumRecvDirect()); } if (_radio) { noise_floor = (int16_t)_radio->getNoiseFloor(); recv_errors = (int)_radio->getPacketsRecvErrors(); } // Internal heap free (for diagnosing repeater hangs from internal heap exhaustion) int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); int len = MQTTMessageBuilder::buildStatusMessage( _status_json_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, tx_air_secs, rx_air_secs, recv_errors, internal_heap_free, packets_sent, packets_received, _prefs->disable_fwd ? "off" : "on" ); if (len > 0) { int result = slot.client->publish(status_topic, 1, true, json_buffer, strlen(json_buffer)); if (result <= 0) { MQTT_DEBUG_PRINTLN("MQTT%d status publish failed", index + 1); } } } void MQTTBridge::updateCachedConnectionStatus() { bool any_connected = false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].connected) { any_connected = true; break; } } _cached_has_connected_slots = any_connected; } bool MQTTBridge::isAnySlotConnected() { for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].enabled && _slots[i].connected) { return true; } } return false; } void MQTTBridge::setSlotPreset(int slot_index, const char* preset_name) { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; // On ESP32, teardown/setup involves TLS and must run on the MQTT task (Core 0). // Set a flag so the MQTT task picks it up on its next loop iteration. #ifdef ESP_PLATFORM if (_mqtt_task_handle != nullptr) { _slot_reconfigure_pending[slot_index] = true; MQTT_DEBUG_PRINTLN("MQTT%d reconfigure queued (preset: %s)", slot_index + 1, preset_name); return; } #endif // Non-ESP32 or bridge not yet started: apply directly applySlotPreset(slot_index, preset_name); } void MQTTBridge::applySlotPreset(int slot_index, const char* preset_name) { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[slot_index]; teardownSlot(slot_index); if (strcmp(preset_name, MQTT_PRESET_NONE) == 0 || preset_name[0] == '\0') { slot.enabled = false; slot.preset = nullptr; return; } if (strcmp(preset_name, MQTT_PRESET_CUSTOM) == 0) { slot.enabled = true; slot.preset = nullptr; // Custom broker settings should already be set via setSlotCustomBroker if (_initialized && customEndpointComplete(slot.host, slot.port)) { setupSlot(slot_index); } return; } const MQTTPresetDef* preset = findMQTTPreset(preset_name); if (preset) { slot.enabled = true; slot.preset = preset; if (mqttPresetNeedsSlotCredentials(preset)) { strncpy(slot.username, _obs->mqtt_slot_username[slot_index], sizeof(slot.username) - 1); slot.username[sizeof(slot.username) - 1] = '\0'; strncpy(slot.password, _obs->mqtt_slot_password[slot_index], sizeof(slot.password) - 1); slot.password[sizeof(slot.password) - 1] = '\0'; } if (_initialized) { char reason[80]; if (!isSlotReady(slot_index, reason, sizeof(reason))) { MQTT_DEBUG_PRINTLN("MQTT%d (%s) not ready — run '%s' to connect", slot_index + 1, preset_name, reason); return; } setupSlot(slot_index); } } } void MQTTBridge::setSlotCustomBroker(int slot_index, const char* host, uint16_t port, const char* username, const char* password) { if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; MQTTSlot& slot = _slots[slot_index]; strncpy(slot.host, host ? host : "", sizeof(slot.host) - 1); slot.host[sizeof(slot.host) - 1] = '\0'; slot.port = port; strncpy(slot.username, username ? username : "", sizeof(slot.username) - 1); slot.username[sizeof(slot.username) - 1] = '\0'; strncpy(slot.password, password ? password : "", sizeof(slot.password) - 1); slot.password[sizeof(slot.password) - 1] = '\0'; } // --------------------------------------------------------------------------- // WiFi connection handling // --------------------------------------------------------------------------- void MQTTBridge::checkConfigurationMismatch() { // Warn if packets are enabled but both rx and tx are off — nothing will be published if (_obs->mqtt_packets_enabled && !_obs->mqtt_rx_enabled && _obs->mqtt_tx_enabled == 0) { unsigned long now = millis(); if (_last_config_warning == 0 || (now - _last_config_warning > CONFIG_WARNING_INTERVAL)) { MQTT_DEBUG_PRINTLN("MQTT: Both mqtt.rx and mqtt.tx are off — no packets will be published. Run 'set mqtt.rx on' or 'set mqtt.tx on' to fix."); _last_config_warning = now; } } else { _last_config_warning = 0; } } bool MQTTBridge::handleWiFiConnection(unsigned long now) { wl_status_t current_wifi_status = WiFi.status(); bool transitioned_to_connected = false; if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) { s_wifi_connected_at = now; } if (!_wifi_status_initialized) { _last_wifi_status = current_wifi_status; _wifi_status_initialized = true; if (current_wifi_status != WL_CONNECTED) { _wifi_disconnected_time = now; } } if (now - _last_wifi_check <= 10000) { return false; } _last_wifi_check = now; if (current_wifi_status == WL_CONNECTED) { if (_last_wifi_status != WL_CONNECTED) { transitioned_to_connected = true; _wifi_disconnected_time = 0; s_wifi_connected_at = now; _wifi_reconnect_backoff_attempt = 0; #ifdef ESP_PLATFORM wifi_ps_type_t ps_mode; uint8_t ps_pref = _obs->wifi_power_save; if (ps_pref == 1) { ps_mode = WIFI_PS_NONE; } else if (ps_pref == 2) { ps_mode = WIFI_PS_MAX_MODEM; } else { ps_mode = WIFI_PS_NONE; // default: no power save; eliminates DTIM wake latency on mains-powered bridges } esp_wifi_set_ps(ps_mode); #ifdef MQTT_WIFI_TX_POWER WiFi.setTxPower(MQTT_WIFI_TX_POWER); #else WiFi.setTxPower(WIFI_POWER_11dBm); #endif #endif } if (s_wifi_connected_at == 0) { s_wifi_connected_at = now; } _last_wifi_status = WL_CONNECTED; } else { if (_last_wifi_status == WL_CONNECTED) { _wifi_disconnected_time = now; s_wifi_connected_at = 0; // Disconnect all slot clients when WiFi drops for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].client && _slots[i].connected) { _slots[i].client->disconnect(); } } } else if (_wifi_disconnected_time > 0) { unsigned long disconnected_duration = now - _wifi_disconnected_time; static const unsigned long WIFI_BACKOFF_MS[] = { 15000, 30000, 60000, 120000, 300000 }; unsigned int idx = (_wifi_reconnect_backoff_attempt < 5) ? _wifi_reconnect_backoff_attempt : 4; unsigned long delay_ms = WIFI_BACKOFF_MS[idx]; unsigned long elapsed_since_attempt = (now >= _last_wifi_reconnect_attempt) ? (now - _last_wifi_reconnect_attempt) : (ULONG_MAX - _last_wifi_reconnect_attempt + now + 1); if (disconnected_duration >= delay_ms && elapsed_since_attempt >= delay_ms) { _last_wifi_reconnect_attempt = now; if (_wifi_reconnect_backoff_attempt < 5) { _wifi_reconnect_backoff_attempt++; } WiFi.disconnect(); WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); } } _last_wifi_status = current_wifi_status; } return transitioned_to_connected; } bool MQTTBridge::isReady() const { return _initialized && isWiFiConfigValid(_obs); } bool MQTTBridge::isIATAValid() const { if (strlen(_iata) == 0 || strcmp(_iata, "XXX") == 0) { return false; } return true; } bool MQTTBridge::isSlotReady(int index, char* reason_buf, size_t reason_size) const { if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false; const MQTTSlot& slot = _slots[index]; if (!slot.enabled) return true; // disabled slots are "ready" (nothing to do) if (slot.preset) { if (slot.preset->topic_style == MQTT_TOPIC_MESHRANK) { if (_obs->mqtt_slot_token[index][0] == '\0') { if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.token ", index + 1); return false; } } else if (slot.preset->topic_style == MQTT_TOPIC_MESHCORE) { if (!isIATAValid()) { if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt.iata "); return false; } } if (mqttPresetNeedsSlotCredentials(slot.preset)) { if (_obs->mqtt_slot_username[index][0] == '\0') { if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.username ", index + 1); return false; } if (_obs->mqtt_slot_password[index][0] == '\0') { if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt%d.password ", index + 1); return false; } } } else { // Custom slot without a topic template uses meshcore format, needs IATA if (_obs->mqtt_slot_topic[index][0] == '\0' && !isIATAValid()) { if (reason_buf) snprintf(reason_buf, reason_size, "set mqtt.iata or set mqtt%d.topic