Web: add RX error rate sparkline and HUD meter

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)
Этот коммит содержится в:
Valentin V. Bartenev
2026-05-09 19:45:36 +03:00
родитель 3854abfd50
Коммит c3ab904361
2 изменённых файлов: 40 добавлений и 5 удалений
+20 -1
Просмотреть файл
@@ -233,6 +233,19 @@ bool buildPointValue(const HistorySample& sample, const HistorySample* previous,
value = static_cast<int>(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 {
+20 -4
Просмотреть файл
@@ -1386,7 +1386,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
<div class="metric-label">${escapeHtml(label)}</div>
<div class="hud-value">${escapeHtml(value)}</div>
</div>
<div class="hud-sub">${escapeHtml(note)}</div>
${note ? `<div class="hud-sub">${escapeHtml(note)}</div>` : ""}
</div>
<div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%;${invertFill ? "margin-left:auto;" : ""}"></div></div>
</div>`;
@@ -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 `<section class="hud-card">
<h3>Packets</h3>
${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)}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;min-width:0">
${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)}
</div>
${renderMeter("RX Error Rate", Math.round(errorRatePct) + "%", errorRatePct, recvErrors + " errors / " + totalAttempts + " attempts", false)}
<div class="metric-grid">
${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");
}