From eaf3c4c2631d168dbb218fa121ffce0bc292c648 Mon Sep 17 00:00:00 2001 From: "Valentin V. Bartenev" Date: Sat, 9 May 2026 22:31:02 +0300 Subject: [PATCH] Stats: add CPU load trend series to history and web panel Wire the 1-minute CPU load average into the stats history pipeline: - Rename HistorySample::reserved to load_avg1_pct (uint8_t, 0-100); struct size unchanged. - Populate load_avg1_pct in updateStatsHistory on ESP32 from _cpu_tracker.getLoadAvg1() * 100. - Register "cpu_load" series in buildPointValue / seriesTitle / seriesUnit so buildSeriesJson serves it via /api/stats?series=cpu_load. - Persist load_avg1_pct as a 29th CSV column in flushSummaryLog. parseSummaryLine accepts both 28-column (old, load_avg1_pct=0) and 29-column (new) formats for backward compatibility. - Add "cpu_load" to the web panel trend card order with orange sparkline color (#e07b39), "N %" hover formatting, and a Y-axis floored at 0 with a 10% minimum ceiling. --- examples/simple_repeater/MyMesh.cpp | 1 + src/helpers/StatsHistory.cpp | 24 +++++++++++++++++++----- src/helpers/StatsHistory.h | 2 +- src/helpers/web/WebPanelServer.cpp | 9 +++++++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 71c334fb..a5a234f7 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1941,6 +1941,7 @@ void MyMesh::updateStatsHistory(unsigned long now_ms) { sample.heap_min = ESP.getMinFreeHeap(); sample.psram_free = ESP.getFreePsram(); sample.psram_min = ESP.getMinFreePsram(); + sample.load_avg1_pct = (uint8_t)(_cpu_tracker.getLoadAvg1() * 100.0f + 0.5f); #endif if (board.isExternalPowered()) sample.flags |= HISTORY_FLAG_EXTERNAL_POWER; if (board.isCharging()) sample.flags |= HISTORY_FLAG_CHARGING; diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 7f2c129b..7416c805 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -211,6 +211,10 @@ bool buildPointValue(const HistorySample& sample, const HistorySample* previous, value = static_cast(sample.battery_mv); return true; } + if (strcmp(series, "cpu_load") == 0) { + value = static_cast(sample.load_avg1_pct); + return true; + } if (strcmp(series, "memory") == 0) { value = static_cast(sample.heap_free); return true; @@ -309,6 +313,9 @@ const char* seriesTitle(const char* series) { if (strcmp(series, "battery") == 0) { return "Battery"; } + if (strcmp(series, "cpu_load") == 0) { + return "CPU Load"; + } if (strcmp(series, "memory") == 0) { return "Heap Free"; } @@ -355,6 +362,9 @@ const char* seriesUnit(const char* series) { if (strcmp(series, "battery") == 0) { return "mV"; } + if (strcmp(series, "cpu_load") == 0) { + return "pct"; + } if (strcmp(series, "memory") == 0) { return "bytes"; } @@ -639,9 +649,10 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con long gps_lon_e6 = 0; unsigned sensor_flags = 0; unsigned gps_satellites = 0; + unsigned load_avg1_pct = 0; int parsed = sscanf(line, - "%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u", + "%lu,%lu,%u,%u,%d,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u,%u", &epoch_secs, &uptime_secs, &battery_mv, @@ -669,8 +680,9 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con &gps_lat_e6, &gps_lon_e6, &sensor_flags, - &gps_satellites); - if (parsed == 28) { + &gps_satellites, + &load_avg1_pct); + if (parsed == 29 || parsed == 28) { memset(&sample, 0, sizeof(sample)); sample.epoch_secs = static_cast(epoch_secs); sample.uptime_secs = static_cast(uptime_secs); @@ -703,6 +715,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con sample.gps_satellites = static_cast(gps_satellites); sample.flags = static_cast(flags); sample.battery_pct = -1; + sample.load_avg1_pct = (parsed == 29) ? static_cast(load_avg1_pct) : 0; return true; } @@ -1087,7 +1100,7 @@ void StatsHistory::flushSummaryLog() { char line[384]; snprintf(line, sizeof(line), - "%lu,%lu,%u,%u,%d,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u\n", + "%lu,%lu,%u,%u,%d,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%d,%d,%u,%u,%d,%d,%ld,%ld,%u,%u,%u\n", static_cast(latest.epoch_secs), static_cast(latest.uptime_secs), static_cast(latest.battery_mv), @@ -1115,7 +1128,8 @@ void StatsHistory::flushSummaryLog() { static_cast(latest.gps_lat_e6), static_cast(latest.gps_lon_e6), static_cast(latest.sensor_flags), - static_cast(latest.gps_satellites)); + static_cast(latest.gps_satellites), + static_cast(latest.load_avg1_pct)); File latest_file = openArchiveWriteWithRecovery(_archive, kSummaryLatestPath); if (!latest_file) { diff --git a/src/helpers/StatsHistory.h b/src/helpers/StatsHistory.h index 9a10b23e..40119a3a 100644 --- a/src/helpers/StatsHistory.h +++ b/src/helpers/StatsHistory.h @@ -37,7 +37,7 @@ struct HistorySample { uint8_t gps_satellites; uint8_t flags; int8_t battery_pct; - uint8_t reserved; + uint8_t load_avg1_pct; }; struct HistoryEvent { diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index e14c67f6..e845469f 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1778,6 +1778,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (!Number.isFinite(value)) return "--"; if (key === "battery") return Math.round(value) + " mV"; if (key === "voltage") return (value / 100).toFixed(2) + " V"; + if (key === "cpu_load") return Math.round(value) + " %"; if (key === "memory") return formatBytes(value); if (key === "packets") return Math.round(value) + " pkts"; if (key === "error_rate") return (value / 10).toFixed(1) + " %"; @@ -1817,6 +1818,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (recent >= 750) return "#d7a531"; // high return "#2f8f4e"; // normal } + if (key === "cpu_load") return "#e07b39"; if (key === "error_rate") return "#ef4444"; if (key === "gps_satellites") return "#2f8f4e"; if (key === "signal") return "#3b82f6"; @@ -1836,6 +1838,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( return baseColor || "#2f8f4e"; } function sparkValueRange(key, values) { + if (key === "cpu_load") { + return { min: 0, max: Math.max(10, ...values) }; + } if (key === "packets" || key === "gps_satellites") { const maxValue = Math.max(1, ...values); return { min:0, max:maxValue }; @@ -2086,9 +2091,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const sensors = summaryPayload && summaryPayload.sensors ? summaryPayload.sensors : null; const gpsEnabled = !!(sensors && sensors.gps_enabled === true); const mcuTempPresent = !!(sensors && Number.isFinite(sensors.mcu_temp_c)); - const order = ["battery", "memory", "packets", "error_rate", "signal", "noise_floor"]; + const order = ["battery", "cpu_load", "memory", "packets", "error_rate", "signal", "noise_floor"]; if (mcuTempPresent) { - order.splice(2, 0, "mcu_temp"); + order.splice(1, 0, "mcu_temp"); } if (gpsEnabled) { order.push("gps_satellites");