From b2ea2f882d5804e0304d151c1fffb9e75506d647 Mon Sep 17 00:00:00 2001 From: Jared Dohrman Date: Thu, 16 Apr 2026 18:09:07 +1000 Subject: [PATCH] fix(stats): lazy-init web stats and degrade non-PSRAM boards to live-only --- examples/simple_repeater/MyMesh.cpp | 90 ++++++++++------- src/helpers/StatsHistory.cpp | 147 +++++++++++++++++++++------- src/helpers/StatsHistory.h | 11 +++ 3 files changed, 179 insertions(+), 69 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8becb028..efc881dc 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1077,7 +1077,7 @@ void MyMesh::begin(FILESYSTEM *fs, ArchiveStorage* archive) { web.setNetworkStateProvider(&network); web.begin(_fs); _stats_history.begin(web.isWebStatsEnabled(), _archive); - if (web.isWebStatsEnabled() && _archive != nullptr && _archive->isMounted()) { + if (web.isWebStatsEnabled() && !_stats_history.isLiveOnly() && _archive != nullptr && _archive->isMounted()) { restoreArchiveNeighbours(); next_archive_neighbours_flush_ms = millis() + kArchiveNeighboursFlushIntervalMs; } @@ -1449,6 +1449,7 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { _archive_neighbours_dirty = false; return; } + _stats_history.maybeReleaseIdleBuffers(now_ms); const bool wifi_connected = network.isWifiConnected(); #ifdef WITH_MQTT_UPLINK @@ -1460,6 +1461,7 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { const bool archive_mounted = (_archive != nullptr) && _archive->isMounted(); #if defined(ESP32) const uint32_t free_heap = ESP.getFreeHeap(); + const uint32_t max_alloc_heap = ESP.getMaxAllocHeap(); const uint32_t uptime_secs = static_cast(uptime_millis / 1000); bool low_memory = _stats_state.low_memory; if (!_stats_state.initialized) { @@ -1493,14 +1495,14 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { if (_stats_state.archive_mounted != archive_mounted) { recordStatsEvent(archive_mounted ? HISTORY_EVENT_ARCHIVE_MOUNTED : HISTORY_EVENT_ARCHIVE_UNAVAILABLE); _stats_state.archive_mounted = archive_mounted; - if (archive_mounted) { + if (!_stats_history.isLiveOnly() && archive_mounted) { if (getNeighbourCount() == 0) { restoreArchiveNeighbours(); } next_archive_neighbours_flush_ms = now_ms + kArchiveNeighboursFlushIntervalMs; } } - if (!_stats_state.low_memory && low_memory) { + if (!_stats_history.isLiveOnly() && !_stats_state.low_memory && low_memory) { #if defined(ESP32) if (_stats_state.last_low_memory_event_uptime_secs == 0 || (uptime_secs - _stats_state.last_low_memory_event_uptime_secs) >= kLowMemoryEventCooldownSecs) { @@ -1513,43 +1515,55 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { _stats_state.low_memory = low_memory; } - if (next_history_sample_ms == 0 || millisHasNowPassed(next_history_sample_ms)) { - HistorySample sample{}; - sample.epoch_secs = getRTCClock()->getCurrentTime(); - sample.uptime_secs = static_cast(uptime_millis / 1000); - sample.packets_sent = radio_driver.getPacketsSent(); - sample.packets_recv = radio_driver.getPacketsRecv(); - sample.battery_mv = board.getBattMilliVolts(); - sample.queue_len = static_cast(_mgr->getOutboundTotal()); - sample.error_flags = _err_flags; - sample.recv_errors = static_cast(min(radio_driver.getPacketsRecvErrors(), 0xFFFF)); - sample.neighbour_count = static_cast(min(getNeighbourCount(), 0xFFFF)); - sample.direct_dups = static_cast(min(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF)); - sample.flood_dups = static_cast(min(((SimpleMeshTables *)getTables())->getNumFloodDups(), 0xFFFF)); - sample.last_rssi_x4 = static_cast(radio_driver.getLastRSSI() * 4.0f); - sample.last_snr_x4 = static_cast(radio_driver.getLastSNR() * 4.0f); - sample.noise_floor = static_cast(_radio->getNoiseFloor()); - sample.battery_pct = static_cast(board.getBatteryPercent()); #if defined(ESP32) - sample.heap_free = ESP.getFreeHeap(); - sample.heap_min = ESP.getMinFreeHeap(); - sample.psram_free = ESP.getFreePsram(); - sample.psram_min = ESP.getMinFreePsram(); + const bool live_stats_headroom_low = + _stats_history.isLiveOnly() && (free_heap <= kLowMemoryClearBytes || max_alloc_heap <= (24UL * 1024UL)); +#else + const bool live_stats_headroom_low = false; #endif - if (board.isExternalPowered()) sample.flags |= HISTORY_FLAG_EXTERNAL_POWER; - if (board.isCharging()) sample.flags |= HISTORY_FLAG_CHARGING; - if (board.isVbusPresent()) sample.flags |= HISTORY_FLAG_VBUS; - if (wifi_connected) sample.flags |= HISTORY_FLAG_WIFI_CONNECTED; - if (mqtt_connected) sample.flags |= HISTORY_FLAG_MQTT_CONNECTED; - if (web.isWebEnabled()) sample.flags |= HISTORY_FLAG_WEB_ENABLED; - if (web_panel_up) sample.flags |= HISTORY_FLAG_WEB_PANEL_UP; - if (archive_mounted) sample.flags |= HISTORY_FLAG_ARCHIVE_MOUNTED; - _stats_history.pushSample(sample); + if (next_history_sample_ms == 0 || millisHasNowPassed(next_history_sample_ms)) { + if (!live_stats_headroom_low) { + HistorySample sample{}; + sample.epoch_secs = getRTCClock()->getCurrentTime(); + sample.uptime_secs = static_cast(uptime_millis / 1000); + sample.packets_sent = radio_driver.getPacketsSent(); + sample.packets_recv = radio_driver.getPacketsRecv(); + sample.battery_mv = board.getBattMilliVolts(); + sample.queue_len = static_cast(_mgr->getOutboundTotal()); + sample.error_flags = _err_flags; + sample.recv_errors = static_cast(min(radio_driver.getPacketsRecvErrors(), 0xFFFF)); + sample.neighbour_count = static_cast(min(getNeighbourCount(), 0xFFFF)); + sample.direct_dups = + static_cast(min(((SimpleMeshTables *)getTables())->getNumDirectDups(), 0xFFFF)); + sample.flood_dups = + static_cast(min(((SimpleMeshTables *)getTables())->getNumFloodDups(), 0xFFFF)); + sample.last_rssi_x4 = static_cast(radio_driver.getLastRSSI() * 4.0f); + sample.last_snr_x4 = static_cast(radio_driver.getLastSNR() * 4.0f); + sample.noise_floor = static_cast(_radio->getNoiseFloor()); + sample.battery_pct = static_cast(board.getBatteryPercent()); +#if defined(ESP32) + sample.heap_free = free_heap; + sample.heap_min = ESP.getMinFreeHeap(); + sample.psram_free = ESP.getFreePsram(); + sample.psram_min = ESP.getMinFreePsram(); +#endif + if (board.isExternalPowered()) sample.flags |= HISTORY_FLAG_EXTERNAL_POWER; + if (board.isCharging()) sample.flags |= HISTORY_FLAG_CHARGING; + if (board.isVbusPresent()) sample.flags |= HISTORY_FLAG_VBUS; + if (wifi_connected) sample.flags |= HISTORY_FLAG_WIFI_CONNECTED; + if (mqtt_connected) sample.flags |= HISTORY_FLAG_MQTT_CONNECTED; + if (web.isWebEnabled()) sample.flags |= HISTORY_FLAG_WEB_ENABLED; + if (web_panel_up) sample.flags |= HISTORY_FLAG_WEB_PANEL_UP; + if (archive_mounted) sample.flags |= HISTORY_FLAG_ARCHIVE_MOUNTED; + _stats_history.pushSample(sample); + } next_history_sample_ms = now_ms + 60000UL; } _stats_history.maybeFlush(now_ms); - maybeFlushArchiveNeighbours(now_ms); + if (!_stats_history.isLiveOnly()) { + maybeFlushArchiveNeighbours(now_ms); + } #else (void)now_ms; #endif @@ -1845,11 +1859,12 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply web.formatWebStatusReply(reply, 160); } else if (strcmp(command, "get web.stats.status") == 0) { snprintf(reply, 160, - "> enabled:%s history:%s psram:%s degraded:%s samples:%u/%u events:%u/%u archive:%s logical:%s path:%s", + "> enabled:%s history:%s psram:%s degraded:%s mode:%s samples:%u/%u events:%u/%u archive:%s logical:%s path:%s", web.isWebStatsEnabled() ? "on" : "off", (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "active" : "inactive", _stats_history.isPsramBacked() ? "yes" : "no", _stats_history.isDegraded() ? "yes" : "no", + _stats_history.isLiveOnly() ? "live" : "full", static_cast(_stats_history.getSampleCount()), static_cast(_stats_history.getSampleCapacity()), static_cast(_stats_history.getEventCount()), @@ -2235,11 +2250,12 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { const char* archive_name = (_archive != nullptr) ? _archive->getLogicalName() : "archive"; const char* archive_path = (_archive != nullptr) ? _archive->getLogicalStatsPath() : "archive:/stats"; const char* archive_type = (_archive != nullptr) ? _archive->getCardTypeName() : "unavailable"; + _stats_history.noteAccess(millis()); size_t offset = 0; offset += snprintf(&reply[offset], reply_size - offset, "{\"enabled\":true," - "\"history\":{\"active\":%s,\"psram\":%s,\"degraded\":%s,\"samples\":%u,\"sample_capacity\":%u,\"sample_interval_secs\":%lu," + "\"history\":{\"active\":%s,\"psram\":%s,\"degraded\":%s,\"live_only\":%s,\"samples\":%u,\"sample_capacity\":%u,\"sample_interval_secs\":%lu," "\"archive_restored\":%s,\"archive_restored_samples\":%u,\"archive_summary_interval_secs\":%lu," "\"events\":%u,\"event_capacity\":%u}," "\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\"," @@ -2256,6 +2272,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) { (_stats_history.isEnabled() && _stats_history.isRecentHistoryAvailable()) ? "true" : "false", _stats_history.isPsramBacked() ? "true" : "false", _stats_history.isDegraded() ? "true" : "false", + _stats_history.isLiveOnly() ? "true" : "false", static_cast(_stats_history.getSampleCount()), static_cast(_stats_history.getSampleCapacity()), static_cast(StatsHistory::getSampleIntervalSecs()), @@ -2339,6 +2356,7 @@ bool MyMesh::formatWebStatsSeriesJson(const char* series, char* reply, size_t re } return false; } + _stats_history.noteAccess(millis()); return _stats_history.buildSeriesJson( series, reply, diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 35d872d0..f5daeb51 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -28,6 +28,8 @@ constexpr uint32_t kEventFlushIntervalMs = 60UL * 1000UL; constexpr size_t kMaxSeriesPoints = 64; constexpr size_t kSummaryRestoreWindowBytes = 16384; constexpr size_t kEventsRestoreWindowBytes = 4096; +constexpr size_t kLiveOnlySampleCapacity = 24; +constexpr size_t kLiveOnlyEventCapacity = 8; struct HistoryCapacityBucket { size_t sample_capacity; @@ -37,7 +39,7 @@ struct HistoryCapacityBucket { HistoryCapacityBucket getHistoryCapacityBucket(bool want_psram) { #if defined(ESP32) if (!want_psram) { - return {96, 32}; + return {kLiveOnlySampleCapacity, kLiveOnlyEventCapacity}; } const uint32_t psram_size = ESP.getPsramSize(); @@ -54,6 +56,14 @@ HistoryCapacityBucket getHistoryCapacityBucket(bool want_psram) { #endif } +bool shouldUseLiveOnlyStats() { +#if defined(ESP32) + return !psramFound(); +#else + return false; +#endif +} + template T* allocHistoryBuffer(size_t count) { #if defined(ESP32) @@ -251,10 +261,10 @@ uint8_t eventTypeFromName(const char* name) { } // namespace StatsHistory::StatsHistory() - : _enabled(false), _psram_backed(false), _degraded(false), _summary_dirty(false), _next_summary_flush_ms(0), - _next_event_flush_ms(0), _sample_capacity(0), _sample_head(0), _sample_count(0), _event_capacity(0), - _event_head(0), _event_count(0), _samples(nullptr), _events(nullptr), _archive(nullptr), _restored_sample_count(0), - _pending_event_count(0) { + : _enabled(false), _activated(false), _psram_backed(false), _degraded(false), _live_only(false), + _summary_dirty(false), _next_summary_flush_ms(0), _next_event_flush_ms(0), _last_access_ms(0), + _sample_capacity(0), _sample_head(0), _sample_count(0), _event_capacity(0), _event_head(0), _event_count(0), + _samples(nullptr), _events(nullptr), _archive(nullptr), _restored_sample_count(0), _pending_event_count(0) { } StatsHistory::~StatsHistory() { @@ -264,11 +274,12 @@ StatsHistory::~StatsHistory() { void StatsHistory::begin(bool enabled, ArchiveStorage* archive) { _archive = archive; + _live_only = shouldUseLiveOnlyStats(); + _degraded = _live_only; + _psram_backed = !_live_only; + _activated = false; + _last_access_ms = 0; _enabled = enabled; - ensureBuffers(); - if (_enabled && _sample_count == 0 && isArchiveAvailable()) { - restoreSummaryLog(); - } _next_summary_flush_ms = millis() + kSummaryFlushIntervalMs; _next_event_flush_ms = millis() + kEventFlushIntervalMs; } @@ -278,13 +289,23 @@ void StatsHistory::setArchive(ArchiveStorage* archive) { } void StatsHistory::setEnabled(bool enabled) { - if (enabled && !ensureBuffers()) { + const bool was_enabled = _enabled; + _live_only = shouldUseLiveOnlyStats(); + _degraded = _live_only; + _psram_backed = !_live_only; + + if (!enabled) { _enabled = false; + releaseBuffers(); return; } - _enabled = enabled; - if (_enabled && _sample_count == 0 && isArchiveAvailable()) { - restoreSummaryLog(); + + _enabled = true; + if (!was_enabled) { + _activated = false; + _last_access_ms = 0; + _next_summary_flush_ms = millis() + kSummaryFlushIntervalMs; + _next_event_flush_ms = millis() + kEventFlushIntervalMs; } } @@ -292,26 +313,42 @@ bool StatsHistory::isArchiveAvailable() const { return _archive != nullptr && _archive->isMounted(); } +bool StatsHistory::activate() { + if (_activated) { + return true; + } + if (!ensureBuffers()) { + return false; + } + _activated = true; + if (supportsPersistence() && isArchiveAvailable()) { + if (_sample_count == 0) { + restoreSummaryLog(); + } + if (_event_count == 0) { + restoreEventsLog(); + } + } + return true; +} + bool StatsHistory::ensureBuffers() { - if (_samples != nullptr && _events != nullptr) { + if (_samples != nullptr && (_event_capacity == 0 || _events != nullptr)) { return true; } -#if defined(ESP32) - const bool want_psram = psramFound(); -#else - const bool want_psram = false; -#endif + const bool want_psram = !shouldUseLiveOnlyStats(); + _live_only = !want_psram; + _degraded = _live_only; + _psram_backed = want_psram; const HistoryCapacityBucket bucket = getHistoryCapacityBucket(want_psram); _sample_capacity = bucket.sample_capacity; _event_capacity = bucket.event_capacity; _samples = allocHistoryBuffer(_sample_capacity); - _events = allocHistoryBuffer(_event_capacity); - _psram_backed = want_psram; - _degraded = !want_psram; + _events = (_event_capacity > 0) ? allocHistoryBuffer(_event_capacity) : nullptr; - if (_samples == nullptr || _events == nullptr) { + if (_samples == nullptr || (_event_capacity > 0 && _events == nullptr)) { freeHistoryBuffer(_samples); freeHistoryBuffer(_events); _samples = allocHistoryBuffer(64); @@ -320,9 +357,53 @@ bool StatsHistory::ensureBuffers() { _event_capacity = (_events != nullptr) ? 24 : 0; _psram_backed = false; _degraded = true; + _live_only = true; } - return _samples != nullptr && _events != nullptr; + return _samples != nullptr && (_event_capacity == 0 || _events != nullptr); +} + +void StatsHistory::releaseBuffers() { + freeHistoryBuffer(_samples); + freeHistoryBuffer(_events); + _samples = nullptr; + _events = nullptr; + _activated = false; + _last_access_ms = 0; + _sample_capacity = 0; + _sample_head = 0; + _sample_count = 0; + _event_capacity = 0; + _event_head = 0; + _event_count = 0; + _summary_dirty = false; + _pending_event_count = 0; + _restored_sample_count = 0; +} + +bool StatsHistory::supportsPersistence() const { + return !_live_only; +} + +bool StatsHistory::isAccessActive(uint32_t now_ms) const { + if (!_live_only) { + return true; + } + return _last_access_ms != 0 && (now_ms - _last_access_ms) < kLiveOnlyIdleTimeoutMs; +} + +void StatsHistory::noteAccess(uint32_t now_ms) { + _last_access_ms = now_ms; + if (_enabled) { + activate(); + } +} + +void StatsHistory::maybeReleaseIdleBuffers(uint32_t now_ms) { + if (!_enabled || !_live_only || !isRecentHistoryAvailable() || isAccessActive(now_ms)) { + return; + } + releaseBuffers(); } void StatsHistory::storeSample(const HistorySample& sample, bool mark_dirty) { @@ -408,7 +489,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con } bool StatsHistory::restoreSummaryLog() { - if (!isArchiveAvailable() || _sample_capacity == 0) { + if (!supportsPersistence() || !isArchiveAvailable() || _sample_capacity == 0) { return false; } @@ -507,7 +588,7 @@ bool StatsHistory::parseEventLine(const char* line, HistoryEvent& event) const { } bool StatsHistory::restoreEventsLog() { - if (!isArchiveAvailable() || _event_capacity == 0) { + if (!supportsPersistence() || !isArchiveAvailable() || _event_capacity == 0) { return false; } @@ -573,7 +654,7 @@ bool StatsHistory::restoreEventsLog() { } void StatsHistory::pushSample(const HistorySample& sample) { - if (!_enabled || !ensureBuffers()) { + if (!_enabled || !_activated || (_live_only && !isAccessActive(millis())) || !ensureBuffers()) { return; } storeSample(sample, true); @@ -590,7 +671,7 @@ void StatsHistory::appendPendingEvent(const HistoryEvent& event) { } void StatsHistory::storeEvent(const HistoryEvent& event, bool queue_pending) { - if (!ensureBuffers()) { + if (_event_capacity == 0 || !ensureBuffers()) { return; } @@ -606,7 +687,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 (!ensureBuffers()) { + if (!_enabled || !_activated || (_live_only && !isAccessActive(millis())) || _event_capacity == 0 || !ensureBuffers()) { return; } @@ -619,7 +700,7 @@ void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptim } void StatsHistory::maybeFlush(uint32_t now_ms) { - if (!_enabled || !isArchiveAvailable()) { + if (!_enabled || !_activated || !supportsPersistence() || !isArchiveAvailable()) { return; } @@ -636,7 +717,7 @@ void StatsHistory::maybeFlush(uint32_t now_ms) { } void StatsHistory::flushSummaryLog() { - if (_archive == nullptr || _sample_count == 0) { + if (!supportsPersistence() || _archive == nullptr || _sample_count == 0) { return; } @@ -686,7 +767,7 @@ void StatsHistory::flushSummaryLog() { } void StatsHistory::flushEventsLog() { - if (_archive == nullptr || _pending_event_count == 0) { + if (!supportsPersistence() || _archive == nullptr || _pending_event_count == 0) { return; } @@ -724,7 +805,7 @@ void StatsHistory::flushEventsLog() { } void StatsHistory::writeMetaFile() const { - if (_archive == nullptr) { + if (!supportsPersistence() || _archive == nullptr) { return; } FILESYSTEM* fs = _archive->getFS(); diff --git a/src/helpers/StatsHistory.h b/src/helpers/StatsHistory.h index e0172197..040aa858 100644 --- a/src/helpers/StatsHistory.h +++ b/src/helpers/StatsHistory.h @@ -65,6 +65,7 @@ class StatsHistory { public: static constexpr uint32_t kSampleIntervalSecs = 60; static constexpr uint32_t kArchiveSummaryIntervalSecs = 300; + static constexpr uint32_t kLiveOnlyIdleTimeoutMs = 2UL * 60UL * 1000UL; StatsHistory(); ~StatsHistory(); @@ -77,6 +78,7 @@ public: bool isRecentHistoryAvailable() const { return _samples != nullptr; } bool isPsramBacked() const { return _psram_backed; } bool isDegraded() const { return _degraded; } + bool isLiveOnly() const { return _live_only; } bool isArchiveAvailable() const; bool hasArchiveRestore() const { return _restored_sample_count > 0; } @@ -89,6 +91,8 @@ public: void pushSample(const HistorySample& sample); void recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value = 0); void maybeFlush(uint32_t now_ms); + void noteAccess(uint32_t now_ms); + void maybeReleaseIdleBuffers(uint32_t now_ms); bool buildSeriesJson(const char* series, char* buffer, size_t buffer_size, uint32_t now_epoch_secs, uint32_t now_uptime_secs) const; bool getRecentEvent(size_t reverse_index, HistoryEvent& event) const; @@ -98,7 +102,11 @@ public: static constexpr uint32_t getArchiveSummaryIntervalSecs() { return kArchiveSummaryIntervalSecs; } private: + bool activate(); bool ensureBuffers(); + void releaseBuffers(); + bool supportsPersistence() const; + bool isAccessActive(uint32_t now_ms) const; bool restoreSummaryLog(); bool restoreEventsLog(); bool parseSummaryLine(const char* line, HistorySample& sample) const; @@ -112,11 +120,14 @@ private: bool getSampleFromOldest(size_t index, HistorySample& sample) const; bool _enabled; + bool _activated; bool _psram_backed; bool _degraded; + bool _live_only; bool _summary_dirty; uint32_t _next_summary_flush_ms; uint32_t _next_event_flush_ms; + uint32_t _last_access_ms; size_t _sample_capacity; size_t _sample_head; size_t _sample_count;