From 23eb7ffb917d366a6621a9a9216acea4dcfeacf8 Mon Sep 17 00:00:00 2001 From: grigo Date: Mon, 15 Jun 2026 11:17:10 +0300 Subject: [PATCH] added rx Quality --- .../loratester/model/RadioSnapshot.java | 13 ++-- .../loratester/telnet/LoraStatsFormatter.java | 1 + .../loratester/telnet/StatsExtractor.java | 5 +- .../loratester/ui/RadioComparePanel.java | 2 + .../loratester/LoraFrameExtractTest.java | 10 +++ server/static/index.html | 67 +++++++++++++++---- server/static/radio-ui.js | 26 +++++-- 7 files changed, 100 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/grigowashere/loratester/model/RadioSnapshot.java b/app/src/main/java/com/grigowashere/loratester/model/RadioSnapshot.java index 5818a12..dfbd73d 100644 --- a/app/src/main/java/com/grigowashere/loratester/model/RadioSnapshot.java +++ b/app/src/main/java/com/grigowashere/loratester/model/RadioSnapshot.java @@ -29,6 +29,7 @@ public final class RadioSnapshot { public final Double txPktPerS; public final Double rxPktPerS; public final Double perPercent; + public final Double rxQualityPercent; public final Map extraFields; public RadioSnapshot( @@ -46,6 +47,7 @@ public final class RadioSnapshot { Double txPktPerS, Double rxPktPerS, Double perPercent, + Double rxQualityPercent, Map extraFields ) { this.role = role; @@ -62,12 +64,13 @@ public final class RadioSnapshot { this.txPktPerS = txPktPerS; this.rxPktPerS = rxPktPerS; this.perPercent = perPercent; + this.rxQualityPercent = rxQualityPercent; this.extraFields = extraFields != null ? extraFields : Map.of(); } public static RadioSnapshot empty() { return new RadioSnapshot(null, null, null, null, null, null, null, null, - null, null, null, null, null, null, Map.of()); + null, null, null, null, null, null, null, Map.of()); } public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) { @@ -75,7 +78,7 @@ public final class RadioSnapshot { RadioSnapshot snap = empty(); if (roleFallback != null || rssiFallback != null) { return new RadioSnapshot(roleFallback, null, null, null, null, null, - rssiFallback, null, null, null, null, null, null, null, Map.of()); + rssiFallback, null, null, null, null, null, null, null, null, Map.of()); } return snap; } @@ -115,11 +118,12 @@ public final class RadioSnapshot { dbl(o, "tx_pkt_per_s"), dbl(o, "rx_pkt_per_s"), dbl(o, "per_percent"), + dbl(o, "rx_quality_percent"), extra ); } catch (Exception ignored) { return new RadioSnapshot(roleFallback, null, null, null, null, null, - rssiFallback, null, null, null, null, null, null, null, Map.of()); + rssiFallback, null, null, null, null, null, null, null, null, Map.of()); } } @@ -141,6 +145,7 @@ public final class RadioSnapshot { cmp(changed, "packet", packet, prev.packet); cmp(changed, "payload", payload, prev.payload); cmp(changed, "per", perPercent, prev.perPercent); + cmp(changed, "rxQuality", rxQualityPercent, prev.rxQualityPercent); cmp(changed, "txSpeed", txPktPerS, prev.txPktPerS); cmp(changed, "rxSpeed", rxPktPerS, prev.rxPktPerS); cmp(changed, "frequency", frequencyMhz, prev.frequencyMhz); @@ -163,7 +168,7 @@ public final class RadioSnapshot { || n.equals("snr") || n.contains("spreading") || n.contains("bandwidth") || n.equals("packet") || n.contains("packet number") || n.equals("payload") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") - || n.equals("per"); + || n.equals("per") || n.contains("rx quality"); } private static String text(JsonObject o, String key) { diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java b/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java index e9938a8..fcdc036 100644 --- a/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java +++ b/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java @@ -36,6 +36,7 @@ public final class LoraStatsFormatter { StringBuilder sb = new StringBuilder(); appendLine(sb, "RSSI", fmtDbm(s.rssiDbm), "rssi", changed); appendLine(sb, "SNR", fmtSuffix(s.snrDb, " dB"), "snr", changed); + appendLine(sb, "RX Quality", fmtSuffix(s.rxQualityPercent, " %"), "rxQuality", changed); appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed); appendLine(sb, "Payload", s.payload, "payload", changed); appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed); diff --git a/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java b/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java index 737b7b6..e4d5097 100644 --- a/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java +++ b/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java @@ -33,6 +33,8 @@ public class StatsExtractor { private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern RX_QUALITY = Pattern.compile( + "RX Quality\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private final Pattern rssiPattern; private final Pattern rangePattern; @@ -98,6 +100,7 @@ public class StatsExtractor { putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized)); putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized)); putDouble(meta, "per_percent", matchDouble(PER, normalized)); + putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized)); if (!fields.isEmpty()) { meta.put("fields", fields); @@ -140,7 +143,7 @@ public class StatsExtractor { || n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth") || n.equals("packet") || n.contains("packet number") || n.equals("payload") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") - || n.equals("per"); + || n.equals("per") || n.contains("rx quality"); } private static ExtractedStats empty(String frame) { diff --git a/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java b/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java index a4a9924..f45c7cc 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java @@ -87,6 +87,8 @@ public class RadioComparePanel extends LinearLayout { if (dynamic) { addRow(table, "RSSI", fmtDbm(tx.rssiDbm), fmtDbm(rx.rssiDbm), "rssi", changedTx, changedRx); addRow(table, "SNR", fmtSuffix(tx.snrDb, " dB"), fmtSuffix(rx.snrDb, " dB"), "snr", changedTx, changedRx); + addRow(table, "RX Quality", fmtSuffix(tx.rxQualityPercent, " %"), fmtSuffix(rx.rxQualityPercent, " %"), + "rxQuality", changedTx, changedRx); addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx); addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx); addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx); diff --git a/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java b/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java index 83a96f9..dd1bbef 100644 --- a/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java +++ b/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java @@ -86,6 +86,16 @@ public class LoraFrameExtractTest { assertTrue(stats.metaJson.contains("\"fields\"")); } + @Test + public void parsesRxQualityPercent() { + StatsExtractor extractor = StatsExtractor.withDefaults(); + String frame = RECEIVE_FRAME + " RX Quality: 87 %\n"; + StatsExtractor.ExtractedStats stats = extractor.extract(frame); + + assertTrue(stats.metaJson.contains("\"rx_quality_percent\":87")); + assertTrue(!stats.metaJson.contains("RX Quality")); + } + @Test public void splitsTwoFramesByReceiveHeaderWithoutEsc() { List frames = new ArrayList<>(); diff --git a/server/static/index.html b/server/static/index.html index 775d9cd..42ddda8 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -474,6 +474,36 @@ return String(s).replace(/&/g,'&').replace(//g,'>'); } + const RADIO_STATIC_KEY = 'radioStaticOpen'; + + function isRadioStaticOpen(container) { + if (container) { + const d = container.querySelector('details'); + if (d) return d.open; + } + try { + return sessionStorage.getItem(RADIO_STATIC_KEY) === '1'; + } catch (e) { + return false; + } + } + + function setPanelHtml(container, html) { + if (!container) return; + const wasOpen = isRadioStaticOpen(container); + container.innerHTML = html; + const d = container.querySelector('details'); + if (d && wasOpen) d.open = true; + } + + document.addEventListener('toggle', e => { + if (!(e.target instanceof HTMLDetailsElement)) return; + if (!e.target.closest('#stats, #mapModalBody, #timelineStats, #cmdCurrentValues')) return; + try { + sessionStorage.setItem(RADIO_STATIC_KEY, e.target.open ? '1' : '0'); + } catch (err) {} + }, true); + function telemetryToSnap(r) { return RadioUI.parseRadioSnapshot(r.meta, r.role, r.rssi); } @@ -495,14 +525,21 @@ const chRx = RadioUI.diffSnapshots(prevTimelineRxSnap, rxSnap); prevTimelineTxSnap = txSnap; prevTimelineRxSnap = rxSnap; - return RadioUI.renderCompareGrid(txSnap, rxSnap, txId, rxId, chTx, chRx); + return RadioUI.renderCompareGrid( + txSnap, rxSnap, txId, rxId, chTx, chRx, + isRadioStaticOpen(document.getElementById('timelineStats')) + ); } function fillCmdFormFromDevice(d) { if (!d) return; const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi); document.getElementById('cmdCurrentValues').innerHTML = - RadioUI.formatRadioPanel(snap, new Set()); + RadioUI.formatRadioPanel( + snap, + new Set(), + isRadioStaticOpen(document.getElementById('cmdCurrentValues')) + ); if (snap.frequencyMhz != null) { document.getElementById('cmdFq').value = snap.frequencyMhz.toFixed(3); } @@ -1474,14 +1511,14 @@ function openMapModal(html, mode) { if (mode) modalMode = mode; - mapModalBody.innerHTML = html; + setPanelHtml(mapModalBody, html); mapModal.classList.add('open'); loadModalPosition(); } function syncModalHtml(html) { if (!isModalOpen()) return; - mapModalBody.innerHTML = html; + setPanelHtml(mapModalBody, html); } function closeMapModal() { @@ -1703,12 +1740,13 @@ const txTel = nearestTelemetry(telemetryTx, t); const rxTel = nearestTelemetry(telemetryRx, t); - document.getElementById('timelineStats').innerHTML = renderTimelineCompare( + const timelineStatsEl = document.getElementById('timelineStats'); + setPanelHtml(timelineStatsEl, renderTimelineCompare( txTel, rxTel, loadedTxTrack?.device_id, loadedRxTrack?.device_id - ); + )); drawElevationChart({ tx: trackDistanceAtTime(loadedTxTrack, t), rx: trackDistanceAtTime(loadedRxTrack, t) @@ -1735,7 +1773,7 @@ html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}
`; const tel = nearestTelemetry(telemetrySingle, t); const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta); - html += RadioUI.formatRadioPanel(snap, new Set()); + html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody)); if (tel) html += '
' + formatTelemetryRow(tel, new Set()); if (openModal || (isModalOpen() && modalMode === 'timeline')) { openMapModal(html, 'timeline'); @@ -1743,9 +1781,13 @@ } const tel = nearestTelemetry(telemetrySingle, t); const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null); - document.getElementById('timelineStats').innerHTML = tel - ? RadioUI.formatRadioPanel(snap, new Set()) - : 'нет данных'; + const timelineStatsEl = document.getElementById('timelineStats'); + setPanelHtml( + timelineStatsEl, + tel + ? RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(timelineStatsEl)) + : 'нет данных' + ); drawElevationChart({ single: trackDistanceAtTime(track, t) }); } @@ -2147,7 +2189,8 @@ const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi); const changed = RadioUI.diffSnapshots(prevDeviceSnap, snap); prevDeviceSnap = snap; - let html = RadioUI.formatRadioPanel(snap, changed); + const statsEl = document.getElementById('stats'); + let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl)); html += `${escapeHtml(d.device_id)}
Range: ${d.range_m ?? '—'} m
`; if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) { html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}
`; @@ -2165,7 +2208,7 @@ function updateStatsPanel(d, openModal) { const html = buildDeviceStatsHtml(d); - document.getElementById('stats').innerHTML = html; + setPanelHtml(document.getElementById('stats'), html); if (openModal) { openMapModal(html, 'device'); } else if (isModalOpen() && modalMode === 'device' && selectedId === d.device_id) { diff --git a/server/static/radio-ui.js b/server/static/radio-ui.js index 730c372..b6bef6b 100644 --- a/server/static/radio-ui.js +++ b/server/static/radio-ui.js @@ -5,7 +5,7 @@ const KNOWN_LABELS = new Set([ 'send', 'receive', 'frequency', 'power', 'rssi', 'snr', 'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload', - 'on air', 'tx speed', 'rx speed', 'per' + 'on air', 'tx speed', 'rx speed', 'per', 'rx quality' ]); function roleLabel(role) { @@ -38,6 +38,7 @@ txPktPerS: null, rxPktPerS: null, perPercent: null, + rxQualityPercent: null, extraFields: {} }; if (!meta) return snap; @@ -59,9 +60,15 @@ if (o.tx_pkt_per_s != null) snap.txPktPerS = Number(o.tx_pkt_per_s); if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s); if (o.per_percent != null) snap.perPercent = Number(o.per_percent); + if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent); if (o.fields && typeof o.fields === 'object') { for (const [k, v] of Object.entries(o.fields)) { if (!isKnownLabel(k)) snap.extraFields[k] = String(v); + const nk = String(k).toLowerCase().trim(); + if (snap.rxQualityPercent == null && nk.includes('rx quality')) { + const n = parseFloat(String(v).replace('%', '').trim()); + if (!Number.isNaN(n)) snap.rxQualityPercent = n; + } } } return snap; @@ -70,9 +77,10 @@ function diffSnapshots(a, b) { const changed = new Set(); if (!a || !b) return changed; - const keys = ['role', 'rssiDbm', 'snrDb', 'packet', 'payload', 'perPercent', + const keys = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm']; - const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', packet: 'packet', + const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality', + packet: 'packet', payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' }; for (const k of keys) { @@ -84,6 +92,7 @@ const DYNAMIC_ROWS = [ { key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' }, { key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' }, + { key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' }, { key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' }, { key: 'payload', label: 'Payload', fmt: s => s.payload || '—' }, { key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' }, @@ -105,7 +114,7 @@ return String(s).replace(/&/g, '&').replace(//g, '>'); } - function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx) { + function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) { let html = '
'; html += `
TX ${escapeHtml(txId || '—')}`; html += `RX ${escapeHtml(rxId || '—')}
`; @@ -116,7 +125,7 @@ html += `${escapeHtml(row.fmt(txSnap))}`; html += `${escapeHtml(row.fmt(rxSnap))}
`; } - html += '
Статика'; + html += `
Статика`; for (const row of STATIC_ROWS) { const txCls = changedTx && changedTx.has(row.key) ? ' changed' : ''; const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : ''; @@ -128,7 +137,7 @@ return html; } - function formatRadioPanel(snap, changed) { + function formatRadioPanel(snap, changed, staticOpen) { if (!snap) return '—'; const ch = changed || new Set(); let html = ''; @@ -136,7 +145,10 @@ const cls = ch.has(row.key) ? ' class="changed"' : ''; html += `${row.label}: ${escapeHtml(row.fmt(snap))}`; } - html += '
Статика'; + for (const [label, value] of Object.entries(snap.extraFields || {})) { + html += `
${escapeHtml(label)}: ${escapeHtml(value)}
`; + } + html += `
Статика`; for (const row of STATIC_ROWS) { const cls = ch.has(row.key) ? ' class="changed"' : ''; html += `${row.label}: ${escapeHtml(row.fmt(snap))}`;