From d412e3e84aba15d47d0d8ae14fff3856d98463bc Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Fri, 17 Apr 2026 12:17:35 +1000 Subject: [PATCH 1/3] fix: improve repeater stats UX and persist ESP32 fallback clock --- docs/web-panel.md | 6 +- examples/simple_repeater/MyMesh.cpp | 12 +- src/helpers/ESP32Board.h | 61 +++++++-- src/helpers/StatsHistory.cpp | 96 ++++++++++---- src/helpers/mqtt/MQTTUplink.cpp | 24 ++++ src/helpers/mqtt/MQTTUplink.h | 1 + src/helpers/web/WebPanelServer.cpp | 194 ++++++++++++++++++++++------ 7 files changed, 313 insertions(+), 81 deletions(-) diff --git a/docs/web-panel.md b/docs/web-panel.md index c3c44de2..dd83ea06 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -187,9 +187,9 @@ The stats page is loaded separately from `/app` and is intended to keep the main The `/stats` page currently shows: -- `Services`: MQTT, web, archive, card, neighbour count, and archive capacity -- `Trends`: battery, heap free, packet activity, and signal -- `Neighbours`: current neighbour table +- `Services`: MQTT, web, archive, neighbour count, and, when mounted, card and archive capacity +- `Trends`: battery, heap free, packet activity, signal, and noise floor +- `Neighbours`: current neighbour table with ID, SNR, heard age, and advert age - `Events`: current boot/session events The trend graphs load sequentially rather than as one large payload: diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index efc881dc..1630b867 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1627,17 +1627,14 @@ bool MyMesh::appendJsonNeighbours(char* reply, size_t reply_size, size_t& offset mesh::Utils::toHex(full_hex, neighbour->id.pub_key, PUB_KEY_SIZE); const uint32_t heard_secs_ago = now_secs - neighbour->heard_timestamp; const uint32_t advert_secs_ago = now_secs - neighbour->advert_timestamp; - ClientInfo* client = const_cast(acl).getClient(neighbour->id.pub_key, PUB_KEY_SIZE); - const bool route_known = (client != nullptr && client->out_path_len != OUT_PATH_UNKNOWN); offset += snprintf(&reply[offset], reply_size - offset, - "%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f,\"route\":\"%s\"}", + "%s{\"id\":\"%s\",\"full_id\":\"%s\",\"heard_secs_ago\":%lu,\"advert_secs_ago\":%lu,\"snr_db\":%.2f}", i == 0 ? "" : ",", hex, full_hex, static_cast(heard_secs_ago), static_cast(advert_secs_ago), - static_cast(neighbour->snr) / 4.0, - route_known ? "known" : "unknown"); + static_cast(neighbour->snr) / 4.0); if (offset >= reply_size) { return false; } @@ -2243,8 +2240,10 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { const bool archive_available = (_archive != nullptr) && _archive->isMounted(); #ifdef WITH_MQTT_UPLINK const bool mqtt_connected = mqtt.isAnyBrokerConnected(); + const char* mqtt_state = mqtt.getAggregateBrokerState(); #else const bool mqtt_connected = false; + const char* mqtt_state = "down"; #endif const bool web_panel_up = web.isPanelRunning(); const char* archive_name = (_archive != nullptr) ? _archive->getLogicalName() : "archive"; @@ -2267,7 +2266,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { "\"recv_errors\":%u,\"direct_dups\":%u,\"flood_dups\":%u,\"neighbors\":%u}," "\"memory\":{\"heap_free\":%u,\"heap_min\":%u,\"heap_max\":%u,\"psram_free\":%u,\"psram_min\":%u,\"psram_max\":%u}," "\"wifi\":{\"ssid\":\"%s\",\"status\":\"%s\",\"connected\":%s,\"state\":\"%s\",\"code\":%d,\"ip\":\"%s\",\"rssi\":%d,\"quality\":%d,\"signal\":\"%s\",\"powersave\":\"%s\"}," - "\"services\":{\"mqtt_connected\":%s,\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\"," + "\"services\":{\"mqtt_connected\":%s,\"mqtt_state\":\"%s\",\"web_enabled\":%s,\"web_panel_up\":%s,\"web_auth\":\"%s\"," "\"archive_available\":%s}", (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "true" : "false", _stats_history.isPsramBacked() ? "true" : "false", @@ -2327,6 +2326,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { wifi_signal, wifi_powersave, mqtt_connected ? "true" : "false", + mqtt_state, web.isWebEnabled() ? "true" : "false", web_panel_up ? "true" : "false", web.isPanelUnlocked() ? "unlocked" : "locked", diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index bade3e89..740b3ea9 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -8,6 +8,7 @@ #include #include #include +#include #include "driver/rtc_io.h" class ESP32Board : public mesh::MainBoard { @@ -129,16 +130,55 @@ public: }; class ESP32RTCClock : public mesh::RTCClock { + Preferences _prefs; + bool _prefs_ready = false; + uint32_t _last_persisted_time = 0; + unsigned long _last_persist_ms = 0; + + static constexpr uint32_t kDefaultEpoch = 1715770351; // 15 May 2024, 8:50pm + static constexpr unsigned long kPersistIntervalMs = 15UL * 60UL * 1000UL; + + void applyTime(uint32_t time) { + struct timeval tv; + tv.tv_sec = time; + tv.tv_usec = 0; + settimeofday(&tv, NULL); + } + + void persistCurrentTime(bool force) { + if (!_prefs_ready) { + return; + } + + const unsigned long now_ms = millis(); + const uint32_t now = getCurrentTime(); + if (!force) { + if (now == 0 || now == _last_persisted_time) { + return; + } + if ((now_ms - _last_persist_ms) < kPersistIntervalMs) { + return; + } + } + + _prefs.putULong("epoch", now); + _last_persisted_time = now; + _last_persist_ms = now_ms; + } + public: ESP32RTCClock() { } void begin() { + _prefs_ready = _prefs.begin("mesh-clock", false); + const uint32_t persisted_epoch = _prefs_ready ? _prefs.getULong("epoch", 0) : 0; esp_reset_reason_t reason = esp_reset_reason(); - if (reason == ESP_RST_POWERON) { + if (persisted_epoch > kDefaultEpoch) { + applyTime(persisted_epoch); + _last_persisted_time = persisted_epoch; + _last_persist_ms = millis(); + } else if (reason == ESP_RST_POWERON) { // start with some date/time in the recent past - struct timeval tv; - tv.tv_sec = 1715770351; // 15 May 2024, 8:50pm - tv.tv_usec = 0; - settimeofday(&tv, NULL); + applyTime(kDefaultEpoch); } } uint32_t getCurrentTime() override { @@ -146,11 +186,12 @@ public: time(&_now); return _now; } - void setCurrentTime(uint32_t time) override { - struct timeval tv; - tv.tv_sec = time; - tv.tv_usec = 0; - settimeofday(&tv, NULL); + void setCurrentTime(uint32_t time) override { + applyTime(time); + persistCurrentTime(true); + } + void tick() override { + persistCurrentTime(false); } }; diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index f5daeb51..aee29c95 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -188,6 +188,9 @@ int buildPointValue(const HistorySample& sample, const HistorySample* previous, if (strcmp(series, "signal") == 0) { return static_cast(sample.last_rssi_x4); } + if (strcmp(series, "noise_floor") == 0) { + return static_cast(sample.noise_floor * 4); + } if (strcmp(series, "packets") == 0) { if (previous == nullptr) { return 0; @@ -212,6 +215,9 @@ const char* seriesTitle(const char* series) { if (strcmp(series, "signal") == 0) { return "Signal"; } + if (strcmp(series, "noise_floor") == 0) { + return "Noise Floor"; + } return ""; } @@ -228,6 +234,9 @@ const char* seriesUnit(const char* series) { if (strcmp(series, "signal") == 0) { return "rssi_x4"; } + if (strcmp(series, "noise_floor") == 0) { + return "noise_floor_x4"; + } return ""; } @@ -329,6 +338,9 @@ bool StatsHistory::activate() { restoreEventsLog(); } } + for (size_t i = 0; i < _pending_event_count; ++i) { + storeEvent(_pending_events[i], false); + } return true; } @@ -432,6 +444,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con unsigned queue_len = 0; int last_rssi_x4 = 0; int last_snr_x4 = 0; + int noise_floor = 0; unsigned long packets_sent = 0; unsigned long packets_recv = 0; unsigned long heap_free = 0; @@ -443,26 +456,58 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con unsigned flood_dups = 0; unsigned flags = 0; - const int parsed = sscanf(line, - "%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u", - &epoch_secs, - &uptime_secs, - &battery_mv, - &queue_len, - &last_rssi_x4, - &last_snr_x4, - &packets_sent, - &packets_recv, - &heap_free, - &psram_free, - &error_flags, - &recv_errors, - &neighbour_count, - &direct_dups, - &flood_dups, - &flags); - if (parsed != 16) { - return false; + int parsed = sscanf(line, + "%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u", + &epoch_secs, + &uptime_secs, + &battery_mv, + &queue_len, + &last_rssi_x4, + &last_snr_x4, + &noise_floor, + &packets_sent, + &packets_recv, + &heap_free, + &psram_free, + &error_flags, + &recv_errors, + &neighbour_count, + &direct_dups, + &flood_dups, + &flags); + if (parsed != 17) { + noise_floor = 0; + packets_sent = 0; + packets_recv = 0; + heap_free = 0; + psram_free = 0; + error_flags = 0; + recv_errors = 0; + neighbour_count = 0; + direct_dups = 0; + flood_dups = 0; + flags = 0; + parsed = sscanf(line, + "%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u", + &epoch_secs, + &uptime_secs, + &battery_mv, + &queue_len, + &last_rssi_x4, + &last_snr_x4, + &packets_sent, + &packets_recv, + &heap_free, + &psram_free, + &error_flags, + &recv_errors, + &neighbour_count, + &direct_dups, + &flood_dups, + &flags); + if (parsed != 16) { + return false; + } } memset(&sample, 0, sizeof(sample)); @@ -483,6 +528,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con sample.flood_dups = static_cast(flood_dups); sample.last_rssi_x4 = static_cast(last_rssi_x4); sample.last_snr_x4 = static_cast(last_snr_x4); + sample.noise_floor = static_cast(noise_floor); sample.flags = static_cast(flags); sample.battery_pct = -1; return true; @@ -687,7 +733,7 @@ void StatsHistory::storeEvent(const HistoryEvent& event, bool queue_pending) { } void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value) { - if (!_enabled || !_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) { + if (!_enabled) { return; } @@ -696,6 +742,11 @@ void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptim event.epoch_secs = epoch_secs; event.uptime_secs = uptime_secs; event.value = value; + + if (!_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) { + appendPendingEvent(event); + return; + } storeEvent(event, true); } @@ -739,13 +790,14 @@ void StatsHistory::flushSummaryLog() { char line[256]; snprintf(line, sizeof(line), - "%lu,%lu,%u,%u,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n", + "%lu,%lu,%u,%u,%d,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n", static_cast(latest.epoch_secs), static_cast(latest.uptime_secs), static_cast(latest.battery_mv), static_cast(latest.queue_len), static_cast(latest.last_rssi_x4), static_cast(latest.last_snr_x4), + static_cast(latest.noise_floor), static_cast(latest.packets_sent), static_cast(latest.packets_recv), static_cast(latest.heap_free), diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index bf0e3abc..93b8185b 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -957,6 +957,29 @@ bool MQTTUplink::isAnyBrokerConnected() const { return false; } +const char* MQTTUplink::getAggregateBrokerState() const { + uint8_t enabled_count = 0; + uint8_t connected_count = 0; + + for (const BrokerState& broker : _brokers) { + if (broker.spec == nullptr || (broker.spec->bit & _prefs.enabled_mask) == 0) { + continue; + } + enabled_count++; + if (broker.connected) { + connected_count++; + } + } + + if (enabled_count == 0 || connected_count == 0) { + return "down"; + } + if (connected_count < enabled_count) { + return "degraded"; + } + return "up"; +} + #else MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&) @@ -983,6 +1006,7 @@ bool MQTTUplink::setOwnerPublicKey(const char*) { return false; } 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"; } #endif diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 2168914f..3197d857 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -62,6 +62,7 @@ public: const char* getOwnerEmail() const { return _prefs.owner_email; } bool sendStatusNow(); bool isAnyBrokerConnected() const; + const char* getAggregateBrokerState() const; void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; } private: diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 5b7c0a7d..cd15eb43 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -256,6 +256,11 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(