Jelajahi Sumber

Redesign approach for CPU usage tracking

The previous implementation maintained three exponential moving averages
(1/5/15-minute) of CPU busy-fraction, sampled every 5 seconds. This had
two problems:

1. Wrong semantics: the metric was labelled "load_avg" (a Unix concept
   measuring run-queue depth), but the tracker actually measures CPU
   busy-time as a fraction of FreeRTOS ticks — i.e. utilization, not
   load.  The name was misleading.

2. Poor responsiveness: the 1-minute EMA (DECAY = exp(-5/60) ≈ 0.920)
   has a ~60-second time constant.  On a live web panel, this made the
   metric appear frozen, especially during short bursts of activity
   (e.g. an HTTP request from the panel itself).

This commit replaces the EMA with a 64-sample simple moving average
(SMA) over a compact uint8_t circular buffer. The sample interval is
60 s / 64 = 937,500 µs, so the window covers exactly 60 seconds:

Algorithm:
- Every 937,500 µs (= 60 s / 64), the FreeRTOS tick delta (busy vs
  idle ticks on core 0) is computed and stored as a uint8_t
  (0–255 = 0–100% utilization).
- The circular buffer holds 64 samples, spanning exactly 60 seconds
  (64 × 937,500 µs = 60,000,000 µs). The timer interval is derived
  directly from the window size: 60 000 000 / SMA_WINDOW µs.
- The index wraps with a bitwise AND (& 63) instead of modulo, since
  64 is a power of 2.
- The running sum is a uint16_t (max 64 × 255 = 16 320, fits easily).
- The result _sma_avg is a volatile float written atomically by the
  esp_timer task (core 0) and read from the main loop (core 1).
  No spinlock is needed: on Xtensa LX7, a 32-bit aligned float store
  is a single instruction.

The 60-second window smooths out short bursts (e.g. WiFi/HTTP spikes)
while reacting to sustained load changes within ~10–15 seconds.

Naming:
- The public API is now getCore0Util() returning a float in [0.0, 1.0].
  The name explicitly identifies which core is measured (core 0, which
  runs WiFi, MQTT, HTTP, and LwIP — see task_pinning.c).
- JSON keys: "load_avg" (array) → "core0_util" (scalar float, percent)
- HistorySample field: load_avg1_pct → core0_util_pct
- StatsHistory series key: "cpu_load" → "core0_util"
- Web panel label: "Load Avg" → "Core0 Util"

The history snapshot (once per minute) reads the same _sma_avg value,
which at that point represents the rolling average of the last 60
seconds — exactly one history interval.
Valentin V. Bartenev 3 bulan lalu
induk
melakukan
a9273f02bb

+ 16 - 13
arch/esp32/CPUUsageTracker.cpp

@@ -6,7 +6,7 @@ volatile uint32_t CPUUsageTracker::s_idle_ticks = 0;
 volatile uint32_t CPUUsageTracker::s_busy_ticks = 0;
 TaskHandle_t      CPUUsageTracker::s_idle_handle = nullptr;
 
-void IRAM_ATTR CPUUsageTracker::s_tick_hook() {   // ← was CPUsageTracker (typo)
+void IRAM_ATTR CPUUsageTracker::s_tick_hook() {
   if (xTaskGetCurrentTaskHandle() == s_idle_handle) {
     s_idle_ticks++;
   } else {
@@ -15,23 +15,26 @@ void IRAM_ATTR CPUUsageTracker::s_tick_hook() {   // ← was CPUsageTracker (typ
 }
 
 void CPUUsageTracker::s_sample_cb(void* arg) {
-  static_cast<CPUUsageTracker*>(arg)->_onSample();  // ← was CpuUsageTracker (old name)
+  static_cast<CPUUsageTracker*>(arg)->_onSample();
 }
 
 void CPUUsageTracker::_onSample() {
-  uint32_t busy = s_busy_ticks;
-  uint32_t idle = s_idle_ticks;
-  uint32_t db = busy - _last_busy;
-  uint32_t di = idle - _last_idle;
+  const uint32_t busy = s_busy_ticks;
+  const uint32_t idle = s_idle_ticks;
+  const uint32_t db = busy - _last_busy;
+  const uint32_t di = idle - _last_idle;
   _last_busy = busy;
   _last_idle = idle;
 
-  uint32_t total = db + di;
-  float sample = (total > 0) ? (float)db / total : 0.0f;
+  const uint32_t total = db + di;
+  const float sample = (total > 0) ? (float)db / (float)total : 0.0f;
 
-  _avg1  = DECAY1  * _avg1  + (1.0f - DECAY1)  * sample;
-  _avg5  = DECAY5  * _avg5  + (1.0f - DECAY5)  * sample;
-  _avg15 = DECAY15 * _avg15 + (1.0f - DECAY15) * sample;
+  const uint8_t s8 = (uint8_t)(sample * 255.0f + 0.5f);
+  _sma_sum -= _sma_buf[_sma_idx];
+  _sma_buf[_sma_idx] = s8;
+  _sma_sum += s8;
+  _sma_idx = (_sma_idx + 1) & (SMA_WINDOW - 1);
+  _sma_avg = (float)_sma_sum * (1.0f / (SMA_WINDOW * 255.0f));
 }
 
 void CPUUsageTracker::begin() {
@@ -42,10 +45,10 @@ void CPUUsageTracker::begin() {
     .callback        = s_sample_cb,
     .arg             = this,
     .dispatch_method = ESP_TIMER_TASK,
-    .name            = "cpu_avg"
+    .name            = "cpu_sample"
   };
   esp_timer_create(&args, &_timer);
-  esp_timer_start_periodic(_timer, 5000000ULL);  // 5 seconds
+  esp_timer_start_periodic(_timer, 60000000ULL / SMA_WINDOW);  // SMA_WINDOW samples per minute
 }
 
 #endif

+ 11 - 11
arch/esp32/CPUUsageTracker.h

@@ -11,14 +11,10 @@ class CPUUsageTracker {
 public:
   void begin();
 
-  float getLoadAvg1()   const { return _avg1; }
-  float getLoadAvg5()   const { return _avg5; }
-  float getLoadAvg15()  const { return _avg15; }
+  float getCore0Util() const { return _sma_avg; }
 
 private:
-  static constexpr float DECAY1  = 0.920044415f;  // exp(-5/60)
-  static constexpr float DECAY5  = 0.983471454f;  // exp(-5/300)
-  static constexpr float DECAY15 = 0.994459848f;  // exp(-5/900)
+  static constexpr uint8_t SMA_WINDOW = 64;  // power of 2 — enables & mask
 
   static volatile uint32_t s_idle_ticks;
   static volatile uint32_t s_busy_ticks;
@@ -27,11 +23,15 @@ private:
   static void IRAM_ATTR s_tick_hook();
   static void           s_sample_cb(void* arg);
 
-  uint32_t _last_idle   = 0;
-  uint32_t _last_busy   = 0;
-  float _avg1           = 0.0f;
-  float _avg5           = 0.0f;
-  float _avg15          = 0.0f;
+  uint32_t _last_idle = 0;
+  uint32_t _last_busy = 0;
+
+  uint8_t  _sma_buf[SMA_WINDOW] = {};
+  uint16_t _sma_sum = 0;
+  uint8_t  _sma_idx = 0;
+
+  volatile float _sma_avg = 0.0f;
+
   esp_timer_handle_t _timer = nullptr;
 
   void _onSample();

+ 5 - 5
examples/simple_repeater/MyMesh.cpp

@@ -1532,10 +1532,10 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) {
 void MyMesh::formatStatsReply(char *reply, size_t reply_size) {
   snprintf(reply,
            reply_size,
-           "{\"battery_mv\":%u,\"uptime_secs\":%u,\"load_avg\":[%.2f,%.2f,%.2f],\"errors\":%u,\"queue_len\":%u}",
+           "{\"battery_mv\":%u,\"uptime_secs\":%u,\"core0_util\":%.1f,\"errors\":%u,\"queue_len\":%u}",
            getBatteryMilliVolts(true),
            _ms->getMillis() / 1000,
-           _cpu_tracker.getLoadAvg1(), _cpu_tracker.getLoadAvg5(), _cpu_tracker.getLoadAvg15(),
+           _cpu_tracker.getCore0Util() * 100.0f,
            _err_flags,
            _mgr->getOutboundTotal());
 }
@@ -1941,7 +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);
+      sample.core0_util_pct = (uint8_t)(_cpu_tracker.getCore0Util() * 100.0f + 0.5f);
 #endif
       if (board.isExternalPowered()) sample.flags |= HISTORY_FLAG_EXTERNAL_POWER;
       if (board.isCharging()) sample.flags |= HISTORY_FLAG_CHARGING;
@@ -2532,7 +2532,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
                      "\"archive\":{\"logical\":\"%s\",\"available\":%s,\"path\":\"%s\",\"type\":\"%s\","
                      "\"total_bytes\":%llu,\"used_bytes\":%llu},"
                      "\"core\":{\"battery_mv\":%u,\"battery_pct\":%d,\"battery_display_pct\":%d,\"battery_min_mv\":%u,\"battery_max_mv\":%u,"
-                     "\"uptime_secs\":%lu,\"load_avg\":[%.2f,%.2f,%.2f],\"errors\":%u,\"queue_len\":%u,"
+                     "\"uptime_secs\":%lu,\"core0_util\":%.1f,\"errors\":%u,\"queue_len\":%u,"
                      "\"external_power\":%s,\"charging\":%s,\"vbus\":%s},"
                      "\"radio\":{\"noise_floor\":%d,\"last_rssi\":%.2f,\"last_snr\":%.2f,\"tx_air_secs\":%lu,\"rx_air_secs\":%lu},"
                      "\"packets\":{\"recv\":%u,\"sent\":%u,\"flood_tx\":%u,\"direct_tx\":%u,\"flood_rx\":%u,\"direct_rx\":%u,"
@@ -2565,7 +2565,7 @@ bool MyMesh::formatWebStatsSummaryJson(char* reply, size_t reply_size) {
                      battery_min_mv,
                      battery_max_mv,
                      static_cast<unsigned long>(uptime_millis / 1000),
-                     _cpu_tracker.getLoadAvg1(), _cpu_tracker.getLoadAvg5(), _cpu_tracker.getLoadAvg15(),
+                     _cpu_tracker.getCore0Util() * 100.0f,
                      _err_flags,
                      static_cast<unsigned>(_mgr->getOutboundTotal()),
                      board.isExternalPowered() ? "true" : "false",

+ 9 - 9
src/helpers/StatsHistory.cpp

@@ -211,8 +211,8 @@ bool buildPointValue(const HistorySample& sample, const HistorySample* previous,
     value = static_cast<int>(sample.battery_mv);
     return true;
   }
-  if (strcmp(series, "cpu_load") == 0) {
-    value = static_cast<int>(sample.load_avg1_pct);
+  if (strcmp(series, "core0_util") == 0) {
+    value = static_cast<int>(sample.core0_util_pct);
     return true;
   }
   if (strcmp(series, "memory") == 0) {
@@ -313,8 +313,8 @@ 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, "core0_util") == 0) {
+    return "Core0 Util";
   }
   if (strcmp(series, "memory") == 0) {
     return "Heap Free";
@@ -362,7 +362,7 @@ const char* seriesUnit(const char* series) {
   if (strcmp(series, "battery") == 0) {
     return "mV";
   }
-  if (strcmp(series, "cpu_load") == 0) {
+  if (strcmp(series, "core0_util") == 0) {
     return "pct";
   }
   if (strcmp(series, "memory") == 0) {
@@ -649,7 +649,7 @@ 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;
+  unsigned core0_util_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,%u",
@@ -681,7 +681,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
                       &gps_lon_e6,
                       &sensor_flags,
                       &gps_satellites,
-                      &load_avg1_pct);
+                      &core0_util_pct);
   if (parsed == 29 || parsed == 28) {
     memset(&sample, 0, sizeof(sample));
     sample.epoch_secs = static_cast<uint32_t>(epoch_secs);
@@ -715,7 +715,7 @@ bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) con
     sample.gps_satellites = static_cast<uint8_t>(gps_satellites);
     sample.flags = static_cast<uint8_t>(flags);
     sample.battery_pct = -1;
-    sample.load_avg1_pct = (parsed == 29) ? static_cast<uint8_t>(load_avg1_pct) : 0;
+    sample.core0_util_pct = (parsed == 29) ? static_cast<uint8_t>(core0_util_pct) : 0;
     return true;
   }
 
@@ -1129,7 +1129,7 @@ void StatsHistory::flushSummaryLog() {
            static_cast<long>(latest.gps_lon_e6),
            static_cast<unsigned>(latest.sensor_flags),
            static_cast<unsigned>(latest.gps_satellites),
-           static_cast<unsigned>(latest.load_avg1_pct));
+           static_cast<unsigned>(latest.core0_util_pct));
 
   File latest_file = openArchiveWriteWithRecovery(_archive, kSummaryLatestPath);
   if (!latest_file) {

+ 1 - 1
src/helpers/StatsHistory.h

@@ -37,7 +37,7 @@ struct HistorySample {
   uint8_t gps_satellites;
   uint8_t flags;
   int8_t battery_pct;
-  uint8_t load_avg1_pct;
+  uint8_t core0_util_pct;
 };
 
 struct HistoryEvent {

+ 5 - 14
src/helpers/web/WebPanelServer.cpp

@@ -531,8 +531,6 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     :root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); }
     .metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
     .metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); }
-    .metric-spread { display:flex; justify-content:space-between; margin-top:4px; }
-    .metric-spread span { font-size:16px; font-weight:700; color:var(--text); }
     .events-table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; }
     .events-table { width:100%; border-collapse:collapse; }
     .events-table th, .events-table td { padding:10px 12px; text-align:left; font-size:13px; }
@@ -1490,14 +1488,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
         <div class="metric-grid core-metrics">
           ${renderMetric("Uptime", formatDuration(core.uptime_secs))}
           ${renderMetric("Battery", (core.battery_mv || 0) + " mV")}
-          ${Array.isArray(core.load_avg) ? `<div class="metric">
-          <div class="metric-label">Load Avg</div>
-          <div class="metric-spread">
-            <span>${core.load_avg[0].toFixed(2)}</span>
-            <span>${core.load_avg[1].toFixed(2)}</span>
-            <span>${core.load_avg[2].toFixed(2)}</span>
-          </div>
-          </div>` : ""}
+          ${renderMetric("Core0 Util", (core.core0_util || 0) + " %")}
           ${renderMetric("Queue", String(core.queue_len ?? 0))}
           ${renderMetric("Errors", String(core.errors ?? 0))}
         </div>
@@ -1779,7 +1770,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
     const kTrendFmt = {
       battery: [v => Math.round(v), "mV"],
       voltage: [v => (v/100).toFixed(2), "V"],
-      cpu_load: [v => Math.round(v), "%"],
+      core0_util: [v => v, "%"],
       memory: [v => formatBytes(v), ""],
       packets: [v => Math.round(v), "pkts"],
       error_rate: [v => (v/10).toFixed(1), "%"],
@@ -1827,7 +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 === "core0_util") return "#e07b39";
       if (key === "error_rate") return "#ef4444";
       if (key === "gps_satellites") return "#2f8f4e";
       if (key === "signal") return "#3b82f6";
@@ -1847,7 +1838,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
       return baseColor || "#2f8f4e";
     }
     function sparkValueRange(key, values) {
-      if (key === "cpu_load") {
+      if (key === "core0_util") {
         return { min: 0, max: Math.max(10, ...values) };
       }
       if (key === "packets" || key === "gps_satellites") {
@@ -2110,7 +2101,7 @@ 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", "cpu_load", "memory", "packets", "error_rate", "signal", "noise_floor"];
+      const order = ["battery", "core0_util", "memory", "packets", "error_rate", "signal", "noise_floor"];
       if (mcuTempPresent) {
         order.splice(1, 0, "mcu_temp");
       }