From 40a1ccab1ed5e08c1aa35a95a3073593f4d002bb Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 16 Jun 2026 12:53:43 +0300 Subject: [PATCH] added more fields --- .../loratester/model/RadioSnapshot.java | 85 +++++++++++++++++-- .../loratester/telnet/LoraStatsFormatter.java | 29 ++++++- .../loratester/telnet/StatsExtractor.java | 57 +++++++++++-- .../loratester/ui/RadioComparePanel.java | 29 ++++++- .../loratester/LoraFrameExtractTest.java | 79 +++++++++++++++-- server/fastapi_app.py | 2 +- server/static/index.html | 2 +- server/static/radio-ui.js | 67 +++++++++++++-- 8 files changed, 319 insertions(+), 31 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 dfbd73d..9ba7fae 100644 --- a/app/src/main/java/com/grigowashere/loratester/model/RadioSnapshot.java +++ b/app/src/main/java/com/grigowashere/loratester/model/RadioSnapshot.java @@ -19,7 +19,7 @@ public final class RadioSnapshot { public final String frame; public final Double frequencyMhz; public final Integer sf; - public final Integer bwKhz; + public final Double bwKhz; public final Double powerDbm; public final Double rssiDbm; public final Double snrDb; @@ -30,6 +30,18 @@ public final class RadioSnapshot { public final Double rxPktPerS; public final Double perPercent; public final Double rxQualityPercent; + public final String codeRate; + public final Integer preambleLength; + public final String lowDataRateOpt; + public final Boolean crcEnabled; + public final Integer payloadLengthBytes; + public final Double txTimeoutMs; + public final Integer packetReceive; + public final Integer packetTotal; + public final Integer packetError; + public final Integer crcError; + public final Integer preambleDetected; + public final Integer headerValid; public final Map extraFields; public RadioSnapshot( @@ -37,7 +49,7 @@ public final class RadioSnapshot { String frame, Double frequencyMhz, Integer sf, - Integer bwKhz, + Double bwKhz, Double powerDbm, Double rssiDbm, Double snrDb, @@ -48,6 +60,18 @@ public final class RadioSnapshot { Double rxPktPerS, Double perPercent, Double rxQualityPercent, + String codeRate, + Integer preambleLength, + String lowDataRateOpt, + Boolean crcEnabled, + Integer payloadLengthBytes, + Double txTimeoutMs, + Integer packetReceive, + Integer packetTotal, + Integer packetError, + Integer crcError, + Integer preambleDetected, + Integer headerValid, Map extraFields ) { this.role = role; @@ -65,12 +89,26 @@ public final class RadioSnapshot { this.rxPktPerS = rxPktPerS; this.perPercent = perPercent; this.rxQualityPercent = rxQualityPercent; + this.codeRate = codeRate; + this.preambleLength = preambleLength; + this.lowDataRateOpt = lowDataRateOpt; + this.crcEnabled = crcEnabled; + this.payloadLengthBytes = payloadLengthBytes; + this.txTimeoutMs = txTimeoutMs; + this.packetReceive = packetReceive; + this.packetTotal = packetTotal; + this.packetError = packetError; + this.crcError = crcError; + this.preambleDetected = preambleDetected; + this.headerValid = headerValid; 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, null, Map.of()); + null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + Map.of()); } public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) { @@ -78,7 +116,9 @@ 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, null, Map.of()); + rssiFallback, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + Map.of()); } return snap; } @@ -108,7 +148,7 @@ public final class RadioSnapshot { text(o, "frame"), hzToMhz(lng(o, "frequency_hz")), integer(o, "spreading_factor"), - integer(o, "bandwidth_khz"), + dbl(o, "bandwidth_khz"), dbl(o, "power_dbm"), rssi, dbl(o, "snr_db"), @@ -119,11 +159,25 @@ public final class RadioSnapshot { dbl(o, "rx_pkt_per_s"), dbl(o, "per_percent"), dbl(o, "rx_quality_percent"), + text(o, "code_rate"), + integer(o, "preamble_length"), + text(o, "low_data_rate_opt"), + bool(o, "crc_enabled"), + integer(o, "payload_length_bytes"), + dbl(o, "tx_timeout_ms"), + integer(o, "packet_receive"), + integer(o, "packet_total"), + integer(o, "packet_error"), + integer(o, "crc_error"), + integer(o, "preamble_detected"), + integer(o, "header_valid"), extra ); } catch (Exception ignored) { return new RadioSnapshot(roleFallback, null, null, null, null, null, - rssiFallback, null, null, null, null, null, null, null, null, Map.of()); + rssiFallback, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, + Map.of()); } } @@ -152,6 +206,14 @@ public final class RadioSnapshot { cmp(changed, "sf", sf, prev.sf); cmp(changed, "bw", bwKhz, prev.bwKhz); cmp(changed, "power", powerDbm, prev.powerDbm); + cmp(changed, "packetReceive", packetReceive, prev.packetReceive); + cmp(changed, "packetTotal", packetTotal, prev.packetTotal); + cmp(changed, "packetError", packetError, prev.packetError); + cmp(changed, "crcError", crcError, prev.crcError); + cmp(changed, "preambleDetected", preambleDetected, prev.preambleDetected); + cmp(changed, "headerValid", headerValid, prev.headerValid); + cmp(changed, "codeRate", codeRate, prev.codeRate); + cmp(changed, "crc", crcEnabled, prev.crcEnabled); return changed; } @@ -167,8 +229,12 @@ public final class RadioSnapshot { || n.contains("frequency") || n.equals("power") || n.equals("rssi") || n.equals("snr") || n.contains("spreading") || n.contains("bandwidth") || n.equals("packet") || n.contains("packet number") || n.equals("payload") + || n.contains("packet receive") || n.contains("packet total") || n.contains("packet error") + || n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") - || n.equals("per") || n.contains("rx quality"); + || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout") + || n.contains("code rate") || n.contains("preamble length") + || n.contains("low data rate") || n.equals("crc") || n.contains("payload length"); } private static String text(JsonObject o, String key) { @@ -186,6 +252,11 @@ public final class RadioSnapshot { return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null; } + private static Boolean bool(JsonObject o, String key) { + JsonElement e = o.get(key); + return e != null && e.isJsonPrimitive() ? e.getAsBoolean() : null; + } + private static Long lng(JsonObject o, String key) { JsonElement e = o.get(key); return e != null && e.isJsonPrimitive() ? e.getAsLong() : null; 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 fcdc036..4d53c4b 100644 --- a/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java +++ b/app/src/main/java/com/grigowashere/loratester/telnet/LoraStatsFormatter.java @@ -40,6 +40,12 @@ public final class LoraStatsFormatter { appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed); appendLine(sb, "Payload", s.payload, "payload", changed); appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed); + appendLine(sb, "Принято", fmtInt(s.packetReceive), "packetReceive", changed); + appendLine(sb, "Всего пакетов", fmtInt(s.packetTotal), "packetTotal", changed); + appendLine(sb, "Ошибки пакетов", fmtInt(s.packetError), "packetError", changed); + appendLine(sb, "CRC Error", fmtInt(s.crcError), "crcError", changed); + appendLine(sb, "Preamble Det.", fmtInt(s.preambleDetected), "preambleDetected", changed); + appendLine(sb, "Header Valid", fmtInt(s.headerValid), "headerValid", changed); appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed); appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed); for (Map.Entry e : s.extraFields.entrySet()) { @@ -58,8 +64,14 @@ public final class LoraStatsFormatter { } appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed); appendLine(sb, "SF", fmtInt(s.sf), "sf", changed); - appendLine(sb, "BW", fmtSuffix(s.bwKhz, " kHz"), "bw", changed); + appendLine(sb, "BW", fmtBw(s.bwKhz), "bw", changed); appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed); + appendLine(sb, "Code Rate", s.codeRate, "codeRate", changed); + appendLine(sb, "Preamble Len", fmtInt(s.preambleLength), "preambleLength", changed); + appendLine(sb, "Low DR Opt", s.lowDataRateOpt, "lowDataRateOpt", changed); + appendLine(sb, "CRC", fmtCrc(s.crcEnabled), "crc", changed); + appendLine(sb, "Payload len", fmtSuffix(s.payloadLengthBytes, " byte"), "payloadLength", changed); + appendLine(sb, "TX Timeout", fmtSuffix(s.txTimeoutMs, " ms"), "txTimeout", changed); appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed); return sb.toString().trim(); } @@ -111,11 +123,22 @@ public final class LoraStatsFormatter { return v != null ? String.valueOf(v) : null; } - private static String fmtSuffix(Double v, String suffix) { - return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null; + private static String fmtBw(Double v) { + return v != null ? String.format(Locale.US, "%.2f kHz", v) : null; + } + + private static String fmtCrc(Boolean enabled) { + if (enabled == null) { + return null; + } + return enabled ? "On" : "Off"; } private static String fmtSuffix(Integer v, String suffix) { return v != null ? v + suffix : null; } + + private static String fmtSuffix(Double v, String suffix) { + return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null; + } } 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 d6b9e04..d481629 100644 --- a/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java +++ b/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java @@ -25,9 +25,26 @@ public class StatsExtractor { private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE); private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); - private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); - private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PACKET_TX = Pattern.compile("(?m)^\\s*Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PACKET_RECEIVE = Pattern.compile("Packet Receive\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PACKET_TOTAL = Pattern.compile("Packet Total\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PACKET_ERROR = Pattern.compile("Packet Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern CRC_ERROR = Pattern.compile("CRC Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PREAMBLE_DETECTED = Pattern.compile( + "Preamble Detected\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern HEADER_VALID = Pattern.compile("Header Valid\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern CODE_RATE = Pattern.compile("Code Rate\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PREAMBLE_LENGTH = Pattern.compile( + "Preamble Length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern LOW_DATA_RATE = Pattern.compile( + "Low Data Rate Opt\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE); + private static final Pattern CRC = Pattern.compile("CRC\\s*:\\s*(On|Off)", Pattern.CASE_INSENSITIVE); + private static final Pattern PAYLOAD_LENGTH = Pattern.compile( + "Payload length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern TX_TIMEOUT = Pattern.compile( + "TX Timeout\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE); private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE); @@ -89,10 +106,10 @@ public class StatsExtractor { putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized)); putInt(meta, "spreading_factor", matchInt(SPREADING, normalized)); - putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized)); + putDouble(meta, "bandwidth_khz", matchDouble(BANDWIDTH, normalized)); Integer packet = matchInt(PACKET_NUMBER, normalized); if (packet == null) { - packet = matchInt(PACKET, normalized); + packet = matchInt(PACKET_TX, normalized); } putInt(meta, "packet", packet); putString(meta, "payload", matchString(PAYLOAD, normalized)); @@ -101,6 +118,18 @@ public class StatsExtractor { 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)); + putString(meta, "code_rate", matchString(CODE_RATE, normalized)); + putInt(meta, "preamble_length", matchInt(PREAMBLE_LENGTH, normalized)); + putString(meta, "low_data_rate_opt", matchString(LOW_DATA_RATE, normalized)); + putBool(meta, "crc_enabled", matchBool(CRC, normalized)); + putInt(meta, "payload_length_bytes", matchInt(PAYLOAD_LENGTH, normalized)); + putDouble(meta, "tx_timeout_ms", matchDouble(TX_TIMEOUT, normalized)); + putInt(meta, "packet_receive", matchInt(PACKET_RECEIVE, normalized)); + putInt(meta, "packet_total", matchInt(PACKET_TOTAL, normalized)); + putInt(meta, "packet_error", matchInt(PACKET_ERROR, normalized)); + putInt(meta, "crc_error", matchInt(CRC_ERROR, normalized)); + putInt(meta, "preamble_detected", matchInt(PREAMBLE_DETECTED, normalized)); + putInt(meta, "header_valid", matchInt(HEADER_VALID, normalized)); if (!fields.isEmpty()) { meta.put("fields", fields); @@ -144,8 +173,12 @@ public class StatsExtractor { return n.equals("frequency") || n.equals("power") || n.equals("rssi") || n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth") || n.equals("packet") || n.contains("packet number") || n.equals("payload") + || n.contains("packet receive") || n.contains("packet total") || n.contains("packet error") + || n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid") || n.contains("on air") || n.contains("tx speed") || n.contains("rx speed") - || n.equals("per") || n.contains("rx quality") || n.equals("timeout"); + || n.equals("per") || n.contains("rx quality") || n.contains("tx timeout") + || n.contains("code rate") || n.contains("preamble length") + || n.contains("low data rate") || n.equals("crc") || n.contains("payload length"); } private static ExtractedStats empty(String frame) { @@ -208,6 +241,20 @@ public class StatsExtractor { } } + private static void putBool(Map meta, String key, Boolean value) { + if (value != null) { + meta.put(key, value); + } + } + + private static Boolean matchBool(Pattern pattern, String text) { + Matcher m = pattern.matcher(text); + if (!m.find()) { + return null; + } + return "on".equalsIgnoreCase(m.group(1).trim()); + } + private static Double matchDouble(Pattern pattern, String text) { Matcher m = pattern.matcher(text); if (m.find()) { 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 f45c7cc..e0dc851 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/RadioComparePanel.java @@ -92,14 +92,30 @@ public class RadioComparePanel extends LinearLayout { 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); + addRow(table, "Принято", fmtInt(tx.packetReceive), fmtInt(rx.packetReceive), "packetReceive", changedTx, changedRx); + addRow(table, "Всего", fmtInt(tx.packetTotal), fmtInt(rx.packetTotal), "packetTotal", changedTx, changedRx); + addRow(table, "Ошибки", fmtInt(tx.packetError), fmtInt(rx.packetError), "packetError", changedTx, changedRx); + addRow(table, "CRC err", fmtInt(tx.crcError), fmtInt(rx.crcError), "crcError", changedTx, changedRx); + addRow(table, "Preamble", fmtInt(tx.preambleDetected), fmtInt(rx.preambleDetected), + "preambleDetected", changedTx, changedRx); + addRow(table, "Header OK", fmtInt(tx.headerValid), fmtInt(rx.headerValid), "headerValid", changedTx, changedRx); addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx); addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx); } else { addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx); addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx); addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx); - addRow(table, "BW", fmtSuffixInt(tx.bwKhz, " kHz"), fmtSuffixInt(rx.bwKhz, " kHz"), "bw", changedTx, changedRx); + addRow(table, "BW", fmtBw(tx.bwKhz), fmtBw(rx.bwKhz), "bw", changedTx, changedRx); addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx); + addRow(table, "Code Rate", str(tx.codeRate), str(rx.codeRate), "codeRate", changedTx, changedRx); + addRow(table, "Preamble", fmtInt(tx.preambleLength), fmtInt(rx.preambleLength), "preambleLength", changedTx, changedRx); + addRow(table, "LDR", str(tx.lowDataRateOpt), str(rx.lowDataRateOpt), "lowDataRateOpt", changedTx, changedRx); + addRow(table, "CRC", fmtCrc(tx.crcEnabled), fmtCrc(rx.crcEnabled), "crc", changedTx, changedRx); + addRow(table, "Payl.len", fmtSuffixInt(tx.payloadLengthBytes, " B"), fmtSuffixInt(rx.payloadLengthBytes, " B"), + "payloadLength", changedTx, changedRx); + addRow(table, "TX Timeout", fmtSuffix(tx.txTimeoutMs, " ms"), fmtSuffix(rx.txTimeoutMs, " ms"), + "txTimeout", changedTx, changedRx); + addRow(table, "On Air", fmtSuffix(tx.onAirMs, " ms"), fmtSuffix(rx.onAirMs, " ms"), "onAir", changedTx, changedRx); } } @@ -183,6 +199,17 @@ public class RadioComparePanel extends LinearLayout { return v != null ? v + suffix : "—"; } + private static String fmtBw(Double v) { + return v != null ? String.format(Locale.US, "%.2f kHz", v) : "—"; + } + + private static String fmtCrc(Boolean enabled) { + if (enabled == null) { + return "—"; + } + return enabled ? "On" : "Off"; + } + private static String fmtSuffixInt(Integer v, String suffix) { return v != null ? v + suffix : "—"; } diff --git a/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java b/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java index dd1bbef..63fd17a 100644 --- a/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java +++ b/app/src/test/java/com/grigowashere/loratester/LoraFrameExtractTest.java @@ -54,11 +54,10 @@ public class LoraFrameExtractTest { public void parsesAllLabeledLinesFromSendScreen() { StatsExtractor extractor = StatsExtractor.withDefaults(); StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND); - assertTrue(stats.metaJson.contains("\"fields\"")); - assertTrue(stats.metaJson.contains("Frequency")); - assertTrue(stats.metaJson.contains("Spreading Factor")); - assertTrue(stats.metaJson.contains("Packet")); - assertTrue(stats.metaJson.contains("Payload")); + assertTrue(stats.metaJson.contains("\"code_rate\":\"4/5\"")); + assertTrue(stats.metaJson.contains("\"spreading_factor\":12")); + assertTrue(stats.metaJson.contains("\"packet\":304")); + assertTrue(stats.metaJson.contains("Test TX!")); } @Test @@ -83,7 +82,8 @@ public class LoraFrameExtractTest { assertEquals(StatsExtractor.ROLE_RX, stats.role); assertEquals(-78.0, stats.rssi, 0.01); assertEquals(10.5, stats.snrDb, 0.01); - assertTrue(stats.metaJson.contains("\"fields\"")); + assertTrue(stats.metaJson.contains("\"packet\":0")); + assertTrue(stats.metaJson.contains("\"rx_pkt_per_s\":0.45")); } @Test @@ -96,6 +96,73 @@ public class LoraFrameExtractTest { assertTrue(!stats.metaJson.contains("RX Quality")); } + @Test + public void parsesRxCountersAndCrcErrors() { + StatsExtractor extractor = StatsExtractor.withDefaults(); + String frame = """ + RECEIVE + Frequency: 433500000 Hz + Power: 0 dBm + Spreading Factor: 5 + Bandwidth: 7.81 kHz + Code Rate: 4/6 + Preamble Length: 8 + Low Data Rate Opt: Off + CRC: Off + Payload length: 32 byte + On Air: 427.14 ms, 2.34 pkt/c + Packet Number: 0 + Payload: test + RSSI: -78 + SNR: 10.5 + RX Speed: 0.45 pkt/s, 120 bit/s + Packet Receive: 12 + Packet Total: 100 + Packet Error: 3 + CRC Error: 2 + PER: 3.00 % + Preamble Detected: 2 + Header Valid: 1 + RX Quality: 87 % + """; + StatsExtractor.ExtractedStats stats = extractor.extract(frame); + assertTrue(stats.metaJson.contains("\"crc_error\":2")); + assertTrue(stats.metaJson.contains("\"packet_error\":3")); + assertTrue(stats.metaJson.contains("\"packet_total\":100")); + assertTrue(stats.metaJson.contains("\"packet_receive\":12")); + assertTrue(stats.metaJson.contains("\"bandwidth_khz\":7.81")); + assertTrue(stats.metaJson.contains("\"crc_enabled\":false")); + assertTrue(stats.metaJson.contains("\"code_rate\":\"4/6\"")); + } + + @Test + public void parsesSendCrcAndConfig() { + StatsExtractor extractor = StatsExtractor.withDefaults(); + String frame = """ + SEND + Frequency: 433500000 Hz + Power: 0 dBm + Spreading Factor: 5 + Bandwidth: 125 kHz + Code Rate: 4/5 + Preamble Length: 8 + Low Data Rate Opt: Off + CRC: On + Payload length: 32 byte + On Air: 23.10 ms, 43.28 pkt/c + Packet: 3816 + Payload: Test TX! + TX Timeout: 0 ms + TX Speed: 32.06 pkt/s, 8206 bit/s + """; + StatsExtractor.ExtractedStats stats = extractor.extract(frame); + assertTrue(stats.metaJson.contains("\"packet\":3816")); + assertTrue(stats.metaJson.contains("\"crc_enabled\":true")); + assertTrue(stats.metaJson.contains("\"payload_length_bytes\":32")); + assertTrue(stats.metaJson.contains("\"tx_timeout_ms\":0")); + assertTrue(stats.metaJson.contains("\"preamble_length\":8")); + } + @Test public void splitsTwoFramesByReceiveHeaderWithoutEsc() { List frames = new ArrayList<>(); diff --git a/server/fastapi_app.py b/server/fastapi_app.py index ed902b1..71cb91e 100644 --- a/server/fastapi_app.py +++ b/server/fastapi_app.py @@ -379,7 +379,7 @@ def health(): return { "ok": status["db_ok"], "ts": time.time(), - "api_build": "2026-06-16f", + "api_build": "2026-06-16g", **status, **elevation_status(), } diff --git a/server/static/index.html b/server/static/index.html index 13a0313..887da72 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -361,7 +361,7 @@ { position: 'topright', collapsed: true } ).addTo(map); - const API_BUILD = '2026-06-16f'; + const API_BUILD = '2026-06-16g'; const markers = {}; let selectedId = null; diff --git a/server/static/radio-ui.js b/server/static/radio-ui.js index ce99848..7700aa7 100644 --- a/server/static/radio-ui.js +++ b/server/static/radio-ui.js @@ -5,7 +5,10 @@ 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', 'rx quality' + 'packet receive', 'packet total', 'packet error', 'crc error', + 'preamble detected', 'header valid', + 'on air', 'tx speed', 'rx speed', 'per', 'rx quality', + 'code rate', 'preamble length', 'low data rate', 'crc', 'payload length', 'tx timeout' ]); function roleLabel(role) { @@ -22,6 +25,15 @@ return false; } + function fmtCrc(enabled) { + if (enabled == null) return '—'; + return enabled ? 'On' : 'Off'; + } + + function fmtBw(khz) { + return khz != null ? `${Number(khz).toFixed(2)} kHz` : '—'; + } + function parseRadioSnapshot(meta, roleFallback, rssiFallback) { const snap = { role: roleFallback || null, @@ -39,6 +51,18 @@ rxPktPerS: null, perPercent: null, rxQualityPercent: null, + codeRate: null, + preambleLength: null, + lowDataRateOpt: null, + crcEnabled: null, + payloadLengthBytes: null, + txTimeoutMs: null, + packetReceive: null, + packetTotal: null, + packetError: null, + crcError: null, + preambleDetected: null, + headerValid: null, extraFields: {} }; if (!meta) return snap; @@ -62,6 +86,18 @@ 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.stats_at != null) snap.statsAt = Number(o.stats_at); + if (o.code_rate != null) snap.codeRate = String(o.code_rate); + if (o.preamble_length != null) snap.preambleLength = Number(o.preamble_length); + if (o.low_data_rate_opt != null) snap.lowDataRateOpt = String(o.low_data_rate_opt); + if (o.crc_enabled != null) snap.crcEnabled = Boolean(o.crc_enabled); + if (o.payload_length_bytes != null) snap.payloadLengthBytes = Number(o.payload_length_bytes); + if (o.tx_timeout_ms != null) snap.txTimeoutMs = Number(o.tx_timeout_ms); + if (o.packet_receive != null) snap.packetReceive = Number(o.packet_receive); + if (o.packet_total != null) snap.packetTotal = Number(o.packet_total); + if (o.packet_error != null) snap.packetError = Number(o.packet_error); + if (o.crc_error != null) snap.crcError = Number(o.crc_error); + if (o.preamble_detected != null) snap.preambleDetected = Number(o.preamble_detected); + if (o.header_valid != null) snap.headerValid = Number(o.header_valid); if (o.fields && typeof o.fields === 'object') { for (const [k, v] of Object.entries(o.fields)) { if (!isKnownLabel(k)) snap.extraFields[k] = String(v); @@ -79,11 +115,16 @@ const changed = new Set(); if (!a || !b) return changed; const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', - 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm']; - const map = { gps: 'gps', packetTime: 'packetTime', 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' }; + 'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid', + 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled']; + const map = { + gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr', + rxQualityPercent: 'rxQuality', packet: 'packet', payload: 'payload', perPercent: 'per', + packetReceive: 'packetReceive', packetTotal: 'packetTotal', packetError: 'packetError', + crcError: 'crcError', preambleDetected: 'preambleDetected', headerValid: 'headerValid', + txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', + frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power', codeRate: 'codeRate', crcEnabled: 'crc' + }; for (const k of keys) { if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]); } @@ -99,6 +140,12 @@ { 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} %` : '—' }, + { key: 'packetReceive', label: 'Принято', fmt: s => s.packetReceive != null ? String(s.packetReceive) : '—' }, + { key: 'packetTotal', label: 'Всего', fmt: s => s.packetTotal != null ? String(s.packetTotal) : '—' }, + { key: 'packetError', label: 'Ошибки', fmt: s => s.packetError != null ? String(s.packetError) : '—' }, + { key: 'crcError', label: 'CRC err', fmt: s => s.crcError != null ? String(s.crcError) : '—' }, + { key: 'preambleDetected', label: 'Preamble', fmt: s => s.preambleDetected != null ? String(s.preambleDetected) : '—' }, + { key: 'headerValid', label: 'Header OK', fmt: s => s.headerValid != null ? String(s.headerValid) : '—' }, { key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' }, { key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' } ]; @@ -107,8 +154,14 @@ { key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) }, { key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' }, { key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' }, - { key: 'bw', label: 'BW', fmt: s => s.bwKhz != null ? `${s.bwKhz} kHz` : '—' }, + { key: 'bw', label: 'BW', fmt: s => fmtBw(s.bwKhz) }, { key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' }, + { key: 'codeRate', label: 'Code Rate', fmt: s => s.codeRate || '—' }, + { key: 'preambleLength', label: 'Preamble', fmt: s => s.preambleLength != null ? String(s.preambleLength) : '—' }, + { key: 'lowDataRateOpt', label: 'LDR', fmt: s => s.lowDataRateOpt || '—' }, + { key: 'crc', label: 'CRC', fmt: s => fmtCrc(s.crcEnabled) }, + { key: 'payloadLength', label: 'Payl.len', fmt: s => s.payloadLengthBytes != null ? `${s.payloadLengthBytes} B` : '—' }, + { key: 'txTimeout', label: 'TX Timeout', fmt: s => s.txTimeoutMs != null ? `${s.txTimeoutMs} ms` : '—' }, { key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' } ];