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)
Этот коммит содержится в:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user