From c3ab90436134293c61182b102a73df784de8ef56 Mon Sep 17 00:00:00 2001 From: "Valentin V. Bartenev" Date: Sat, 9 May 2026 19:45:36 +0300 Subject: [PATCH] Web: add RX error rate sparkline and HUD meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (StatsHistory.cpp): - buildPointValue: add "error_rate" series — computes per-interval receive error rate as (d_errors * 1000) / (d_errors + d_recv), yielding a per-mille value (0–1000); returns false when no previous sample is available or the interval had zero traffic - seriesTitle: register "error_rate" → "Error Rate" - seriesUnit: register "error_rate" → "per_mille" - buildSeriesJson: extend the packets delta-series special case to also cover "error_rate" so the current-value field is populated correctly instead of being emitted as null Frontend (WebPanelServer.cpp): - renderMeter: suppress the hud-sub div when note is empty to avoid blank layout space - renderPacketsCard: compute recv_errors / (recv + recv_errors) as errorRatePct; display it as a dedicated "RX Error Rate" meter with an errors/attempts subtitle; lay out TX and RX flood-share meters side-by-side in a two-column grid to make room - sparkline pipeline: register "error_rate" throughout - sparkFormatValue: (value / 10).toFixed(1) + " %" (per-mille → %) - sparkStrokeColor: fixed red (#ef4444) - sparkValueRange: fixed 0–1000 - sparkBands: green 0–20 %, yellow 20–45 %, red 45–100 % - summary panel order: battery → memory → packets → error_rate → signal → noise_floor (packet throughput and error rate grouped) --- src/helpers/StatsHistory.cpp | 21 ++++++++++++++++++++- src/helpers/web/WebPanelServer.cpp | 24 ++++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/helpers/StatsHistory.cpp b/src/helpers/StatsHistory.cpp index 24e79e28..058988b9 100644 --- a/src/helpers/StatsHistory.cpp +++ b/src/helpers/StatsHistory.cpp @@ -233,6 +233,19 @@ bool buildPointValue(const HistorySample& sample, const HistorySample* previous, value = static_cast(curr_total >= prev_total ? (curr_total - prev_total) : 0); return true; } + if (strcmp(series, "error_rate") == 0) { + if (previous == nullptr) { + return false; + } + const uint32_t d_errors = sample.recv_errors - previous->recv_errors; + const uint32_t d_recv = sample.packets_recv - previous->packets_recv; + const uint32_t total = d_errors + d_recv; + if (total == 0) { + return false; + } + value = (int)((d_errors * 1000u) / total); + return true; + } if (strcmp(series, "voltage") == 0) { if ((sample.sensor_flags & HISTORY_SENSOR_SUPPLY_VOLTAGE) == 0) { return false; @@ -302,6 +315,9 @@ const char* seriesTitle(const char* series) { if (strcmp(series, "packets") == 0) { return "Packet Activity"; } + if (strcmp(series, "error_rate") == 0) { + return "Error Rate"; + } if (strcmp(series, "signal") == 0) { return "Signal"; } @@ -345,6 +361,9 @@ const char* seriesUnit(const char* series) { if (strcmp(series, "packets") == 0) { return "pkts"; } + if (strcmp(series, "error_rate") == 0) { + return "per_mille"; + } if (strcmp(series, "signal") == 0) { return "rssi_x4"; } @@ -1256,7 +1275,7 @@ bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buff bool have_current_value = false; getSampleFromOldest(_sample_count - 1, sample); - if (strcmp(series, "packets") == 0 && _sample_count >= 2) { + if ((strcmp(series, "packets") == 0 || strcmp(series, "error_rate") == 0) && _sample_count >= 2) { getSampleFromOldest(_sample_count - 2, previous); have_current_value = buildPointValue(sample, &previous, series, current_value); } else { diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index ff9d1360..68004a7d 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -1386,7 +1386,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
${escapeHtml(label)}
${escapeHtml(value)}
-
${escapeHtml(note)}
+ ${note ? `
${escapeHtml(note)}
` : ""}
`; @@ -1549,10 +1549,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const directTx = packets.direct_tx || 0; const floodRx = packets.flood_rx || 0; const directRx = packets.direct_rx || 0; + const recvErrors = packets.recv_errors || 0; + const totalAttempts = recv + recvErrors; + const errorRatePct = pctRatio(recvErrors, totalAttempts); return `

Packets

- ${renderMeter("TX Flood Share", Math.round(pctRatio(floodTx, sent)) + "%", pctRatio(floodTx, sent), floodTx + " flood / " + directTx + " direct", false)} - ${renderMeter("RX Flood Share", Math.round(pctRatio(floodRx, recv)) + "%", pctRatio(floodRx, recv), floodRx + " flood / " + directRx + " direct", false)} +
+ ${renderMeter("TX Flood Share", Math.round(pctRatio(floodTx, sent)) + "%", pctRatio(floodTx, sent), "", false)} + ${renderMeter("RX Flood Share", Math.round(pctRatio(floodRx, recv)) + "%", pctRatio(floodRx, recv), "", false)} +
+ ${renderMeter("RX Error Rate", Math.round(errorRatePct) + "%", errorRatePct, recvErrors + " errors / " + totalAttempts + " attempts", false)}
${renderMetric("Sent", sent)} ${renderMetric("Recv", recv)} @@ -1771,6 +1777,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (key === "voltage") return (value / 100).toFixed(2) + " V"; if (key === "memory") return formatBytes(value); if (key === "packets") return Math.round(value) + " pkts"; + if (key === "error_rate") return (value / 10).toFixed(1) + " %"; if (key === "signal") return (value / 4).toFixed(1) + " dBm"; if (key === "noise_floor") return (value / 4).toFixed(1) + " dBm"; if (key === "sensor_temp" || key === "mcu_temp") return (value / 10).toFixed(1) + " C"; @@ -1808,6 +1815,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( if (recent >= 750) return "#d7a531"; // high return "#2f8f4e"; // normal } + if (key === "error_rate") return "#ef4444"; if (key === "gps_satellites") return "#2f8f4e"; if (key === "signal") return "#3b82f6"; if (key === "noise_floor") return "#94a3b8"; @@ -1830,6 +1838,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( const maxValue = Math.max(1, ...values); return { min:0, max:maxValue }; } + if (key === "error_rate") return { min: 0, max: 1000 }; if (key === "signal") { return { min:(-125 * 4), max:(-30 * 4) }; } @@ -1864,6 +1873,13 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( { from: 950, to: 1250, color: "rgba(191,75,75,0.14)" } // critical (> 95 °C) ]; } + if (key === "error_rate") { + return [ + { from: 0, to: 200, color: "rgba(47,143,78,0.12)" }, + { from: 200, to: 450, color: "rgba(215,165,49,0.14)" }, + { from: 450, to: 1000, color: "rgba(191,75,75,0.14)" } + ]; + } if (key === "signal") { return [ { from:(-125 * 4), to:(-110 * 4), color:"rgba(191,75,75,0.14)" }, @@ -2043,7 +2059,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", "memory", "signal", "noise_floor", "packets"]; + const order = ["battery", "memory", "packets", "error_rate", "signal", "noise_floor"]; if (mcuTempPresent) { order.splice(2, 0, "mcu_temp"); }