From c729a58c819b431e67b137873d2a08887ef54174 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Sun, 26 Apr 2026 18:10:33 +1000 Subject: [PATCH] fix: extend MQTT JWT lifetime and suppress reconnect noise during planned refresh --- docs/web-panel.md | 2 ++ examples/simple_repeater/MyMesh.cpp | 19 +++++++++++++++++-- examples/simple_repeater/MyMesh.h | 1 + src/helpers/StatsHistory.cpp | 3 +++ src/helpers/StatsHistory.h | 1 + src/helpers/mqtt/MQTTUplink.cpp | 23 +++++++++++++++++------ src/helpers/mqtt/MQTTUplink.h | 4 ++++ 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/docs/web-panel.md b/docs/web-panel.md index 1847fb6d..18107ebd 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -229,6 +229,8 @@ The `/stats` page currently shows: - `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age - `Events`: current boot/session events +Planned MQTT JWT rotations are shown as `mqtt_token_refreshed` events instead of a short `mqtt_disconnected` / `mqtt_connected` pair. + For boards that expose extra telemetry, the optional `Environment` summary card can show current values such as GPS fix state, latitude, longitude, GPS altitude, voltage, sensor temperature, humidity, barometer, pressure-derived altitude, and MCU temperature. Metrics with no current value are hidden rather than showing placeholder rows, so the cards vary by board and by current sensor state. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a615f00f..fb6a6586 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1793,8 +1793,12 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { const bool wifi_connected = network.isWifiConnected(); #ifdef WITH_MQTT_UPLINK const bool mqtt_connected = mqtt.isAnyBrokerConnected(); + const uint32_t mqtt_token_refresh_count = mqtt.getTokenRefreshCount(); + const bool mqtt_token_refreshing = mqtt.isTokenRefreshInProgress(); #else const bool mqtt_connected = false; + const uint32_t mqtt_token_refresh_count = 0; + const bool mqtt_token_refreshing = false; #endif const bool web_panel_up = web.isPanelRunning(); const bool archive_mounted = (_archive != nullptr) && _archive->isMounted(); @@ -1821,11 +1825,22 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { _stats_state.web_panel_up = web_panel_up; _stats_state.archive_mounted = archive_mounted; _stats_state.low_memory = low_memory; + _stats_state.mqtt_token_refresh_count = mqtt_token_refresh_count; _stats_state.last_low_memory_event_uptime_secs = 0; } else { + if (_stats_state.mqtt_token_refresh_count != mqtt_token_refresh_count) { + const uint32_t refresh_delta = mqtt_token_refresh_count - _stats_state.mqtt_token_refresh_count; + recordStatsEvent(HISTORY_EVENT_MQTT_TOKEN_REFRESHED, + static_cast(min(refresh_delta, 32767))); + _stats_state.mqtt_token_refresh_count = mqtt_token_refresh_count; + } if (_stats_state.mqtt_connected != mqtt_connected) { - recordStatsEvent(mqtt_connected ? HISTORY_EVENT_MQTT_CONNECTED : HISTORY_EVENT_MQTT_DISCONNECTED); - _stats_state.mqtt_connected = mqtt_connected; + if (_stats_state.mqtt_connected && !mqtt_connected && mqtt_token_refreshing) { + // Planned JWT rotation briefly drops MQTT; keep the event stream focused on real outages. + } else { + recordStatsEvent(mqtt_connected ? HISTORY_EVENT_MQTT_CONNECTED : HISTORY_EVENT_MQTT_DISCONNECTED); + _stats_state.mqtt_connected = mqtt_connected; + } } if (_stats_state.web_panel_up != web_panel_up) { recordStatsEvent(web_panel_up ? HISTORY_EVENT_WEB_STARTED : HISTORY_EVENT_WEB_STOPPED); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8d15bb96..da51a500 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -185,6 +185,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, public WebPanelComm bool web_panel_up; bool archive_mounted; bool low_memory; + uint32_t mqtt_token_refresh_count; uint32_t last_low_memory_event_uptime_secs; } _stats_state; diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 24e79e28..538d2dbe 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -396,6 +396,7 @@ uint8_t eventTypeFromName(const char* name) { if (strcmp(name, "low_memory") == 0) return HISTORY_EVENT_LOW_MEMORY; if (strcmp(name, "stats_enabled") == 0) return HISTORY_EVENT_STATS_ENABLED; if (strcmp(name, "stats_disabled") == 0) return HISTORY_EVENT_STATS_DISABLED; + if (strcmp(name, "mqtt_token_refreshed") == 0) return HISTORY_EVENT_MQTT_TOKEN_REFRESHED; return 0; } @@ -1369,6 +1370,8 @@ const char* StatsHistory::getEventTypeName(uint8_t type) { return "stats_enabled"; case HISTORY_EVENT_STATS_DISABLED: return "stats_disabled"; + case HISTORY_EVENT_MQTT_TOKEN_REFRESHED: + return "mqtt_token_refreshed"; default: return "unknown"; } diff --git a/src/helpers/StatsHistory.h b/src/helpers/StatsHistory.h index 9a10b23e..5e19fdc7 100644 --- a/src/helpers/StatsHistory.h +++ b/src/helpers/StatsHistory.h @@ -86,6 +86,7 @@ enum HistoryEventType : uint8_t { HISTORY_EVENT_LOW_MEMORY = 8, HISTORY_EVENT_STATS_ENABLED = 9, HISTORY_EVENT_STATS_DISABLED = 10, + HISTORY_EVENT_MQTT_TOKEN_REFRESHED = 11, }; class StatsHistory { diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index 30ec85ac..8e6eff92 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -43,8 +43,9 @@ namespace { constexpr unsigned long kBrokerRetryBaseMillis = 10000; constexpr unsigned long kBrokerRetryMaxMillis = 300000; +constexpr unsigned long kTokenRefreshEventWindowMs = 2UL * 60UL * 1000UL; constexpr size_t kBrokerTokenSize = 640; -constexpr time_t kTokenLifetimeSecs = 3600; +constexpr time_t kTokenLifetimeSecs = 6UL * 60UL * 60UL; constexpr time_t kTokenRefreshSlackSecs = 300; constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z @@ -104,8 +105,8 @@ const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = { }; MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity) - : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _last_status_publish(0), _last_status{}, - _node_name(nullptr), _network(nullptr) + : _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _last_status_publish(0), + _token_refresh_count(0), _token_refresh_active_until_ms(0), _last_status{}, _node_name(nullptr), _network(nullptr) { memset(_device_id, 0, sizeof(_device_id)); MQTTPrefsStore::setDefaults(_prefs); @@ -124,6 +125,11 @@ bool MQTTUplink::savePrefs() { return MQTTPrefsStore::save(_fs, _prefs); } +bool MQTTUplink::isTokenRefreshInProgress() const { + return _token_refresh_active_until_ms != 0 && + static_cast(millis() - _token_refresh_active_until_ms) < 0; +} + bool MQTTUplink::hasEnabledBroker() const { return (_prefs.enabled_mask & 0x07) != 0; } @@ -622,11 +628,15 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) { } time_t now = time(nullptr); + unsigned long now_ms = millis(); if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) { + _token_refresh_count++; + _token_refresh_active_until_ms = now_ms + kTokenRefreshEventWindowMs; + MQTT_LOG("%s token refresh reconnect count=%lu", broker.spec->label, + static_cast(_token_refresh_count)); destroyBroker(broker, false); } - unsigned long now_ms = millis(); if (broker.client != nullptr) { if (broker.connected) { return; @@ -1000,8 +1010,8 @@ const char* MQTTUplink::getAggregateBrokerState() const { #else MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&) - : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _last_status_publish(0), _last_status{}, - _node_name(nullptr), _network(nullptr) { + : _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _last_status_publish(0), + _token_refresh_count(0), _token_refresh_active_until_ms(0), _last_status{}, _node_name(nullptr), _network(nullptr) { MQTTPrefsStore::setDefaults(_prefs); } @@ -1024,6 +1034,7 @@ bool MQTTUplink::setOwnerEmail(const char*) { return false; } bool MQTTUplink::sendStatusNow() { return false; } bool MQTTUplink::isAnyBrokerConnected() const { return false; } const char* MQTTUplink::getAggregateBrokerState() const { return "down"; } +bool MQTTUplink::isTokenRefreshInProgress() const { return false; } #endif diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index b61ad5a6..a4391109 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -65,6 +65,8 @@ public: bool isAnyBrokerConnected() const; const char* getAggregateBrokerState() const; void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; } + uint32_t getTokenRefreshCount() const { return _token_refresh_count; } + bool isTokenRefreshInProgress() const; private: #if defined(ESP_PLATFORM) @@ -101,6 +103,8 @@ private: MQTTPrefs _prefs; bool _running; unsigned long _last_status_publish; + uint32_t _token_refresh_count; + unsigned long _token_refresh_active_until_ms; MQTTStatusSnapshot _last_status; char _device_id[65]; const char* _node_name;