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 e4d5097..d6b9e04 100644 --- a/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java +++ b/app/src/main/java/com/grigowashere/loratester/telnet/StatsExtractor.java @@ -106,6 +106,8 @@ public class StatsExtractor { meta.put("fields", fields); } + meta.put("stats_at", System.currentTimeMillis() / 1000.0); + Double rangeM = matchDouble(rangePattern, normalized); Double displayDbm = rssiDbm != null ? rssiDbm : txPower; @@ -143,7 +145,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.contains("rx quality"); + || n.equals("per") || n.contains("rx quality") || n.equals("timeout"); } private static ExtractedStats empty(String frame) { diff --git a/server/fastapi_app.py b/server/fastapi_app.py index 45e01c8..ed902b1 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-16e", + "api_build": "2026-06-16f", **status, **elevation_status(), } diff --git a/server/static/index.html b/server/static/index.html index b9b1a85..13a0313 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -59,8 +59,8 @@ .radio-compare-head { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 6px; font-weight: 600; } .radio-row { display: grid; grid-template-columns: 72px 1fr 1fr; gap: 6px; padding: 2px 0; } .radio-label { color: #aaa; } - .radio-tx { color: #e94560; } - .radio-rx { color: #4fc3f7; } + .radio-tx { color: #e94560; min-width: 0; word-break: break-all; } + .radio-rx { color: #4fc3f7; min-width: 0; word-break: break-all; } .radio-row .changed, .changed { background: #e9456033; border-radius: 3px; padding: 0 2px; } .radio-static summary { cursor: pointer; color: #aaa; margin: 4px 0; } #stats .changed { background: #e9456033; border-radius: 3px; } @@ -361,7 +361,7 @@ { position: 'topright', collapsed: true } ).addTo(map); - const API_BUILD = '2026-06-16e'; + const API_BUILD = '2026-06-16f'; const markers = {}; let selectedId = null; @@ -511,7 +511,10 @@ const p = Math.max(0, Math.min(1, progress)); if (points.length === 1) { const one = points[0]; - return { lat: Number(one.lat), lon: Number(one.lon), meta: one.meta, rssi: one.rssi }; + return { + lat: Number(one.lat), lon: Number(one.lon), meta: one.meta, rssi: one.rssi, + pointTs: Number(one.ts), + }; } const f = p * (points.length - 1); const i = Math.floor(f); @@ -520,13 +523,17 @@ const a = points[i]; const b = points[j]; if (frac <= 0 || i === j) { - return { lat: Number(a.lat), lon: Number(a.lon), meta: a.meta, rssi: a.rssi }; + return { + lat: Number(a.lat), lon: Number(a.lon), meta: a.meta, rssi: a.rssi, + pointTs: Number(a.ts), + }; } return { lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac, lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac, meta: frac < 0.5 ? a.meta : b.meta, rssi: frac < 0.5 ? a.rssi : b.rssi, + pointTs: Number(frac < 0.5 ? a.ts : b.ts), }; } @@ -564,7 +571,7 @@ function snapAtCursor(track, telemetryRows, cursor, roleFallback) { const pos = trackPointAt(track, cursor); if (pos?.meta && String(pos.meta).length > 2) { - const ts = timelineUseProgress ? null : cursor.t; + const ts = pos.pointTs ?? (timelineUseProgress ? null : cursor.t); return snapFromTrackPoint(pos, ts, roleFallback || track?.role); } if (timelineUseProgress) { @@ -613,11 +620,12 @@ function snapFromTrackPoint(pos, t, roleFallback) { if (!pos) return null; + const ts = pos.pointTs ?? t; return { meta: pos.meta, role: roleFallback, rssi: pos.rssi, - ts: t, + ts, lat: pos.lat, lon: pos.lon, }; @@ -625,7 +633,12 @@ function mergeTelCoords(tel, pos) { if (!tel || !pos) return tel; - return { ...tel, lat: pos.lat, lon: pos.lon }; + return { + ...tel, + lat: pos.lat, + lon: pos.lon, + ts: tel.ts ?? pos.pointTs ?? null, + }; } function snapAtTime(track, telemetryRows, t, roleFallback) { @@ -772,30 +785,45 @@ return `${Number(tel.lat).toFixed(5)}, ${Number(tel.lon).toFixed(5)}`; } - function formatPacketTime(tel) { - if (!tel) return null; - let ts = tel.ts; - if (tel.meta) { - let o = tel.meta; - if (typeof o === 'string') { - try { o = JSON.parse(o); } catch (e) { o = null; } - } - if (o) { - if (o.packet_ts != null) ts = o.packet_ts; - else if (o.ts != null) ts = o.ts; - else if (o.fields) { - for (const [k, v] of Object.entries(o.fields)) { - if (/time/i.test(k)) return String(v); - } - } - } - } - if (ts == null || !Number.isFinite(Number(ts)) || Number(ts) <= 1) return null; + function formatTsValue(ts) { + if (ts == null || !Number.isFinite(Number(ts))) return null; const n = Number(ts); + if (n <= 1e8) return null; const ms = n < 1e12 ? n * 1000 : n; return new Date(ms).toLocaleTimeString(); } + function packetTimeFromMetaFields(fields) { + if (!fields) return null; + const skip = /timeout|on\s*air|speed|airtime/i; + for (const [k, v] of Object.entries(fields)) { + const key = String(k).trim(); + if (skip.test(key)) continue; + if (!/^(time|timestamp|packet.?time)$/i.test(key)) continue; + const text = String(v).trim(); + if (/^\d+(\.\d+)?\s*ms$/i.test(text)) continue; + const parsed = formatTsValue(text); + if (parsed) return parsed; + if (text) return text; + } + return null; + } + + function formatPacketTime(tel) { + if (!tel) return null; + const direct = formatTsValue(tel.ts); + if (direct) return direct; + if (!tel.meta) return null; + let o = tel.meta; + if (typeof o === 'string') { + try { o = JSON.parse(o); } catch (e) { return null; } + } + if (!o) return null; + const fromMeta = formatTsValue(o.stats_at) || formatTsValue(o.packet_ts) || formatTsValue(o.ts); + if (fromMeta) return fromMeta; + return packetTimeFromMetaFields(o.fields); + } + function enrichSnapFromTel(snap, tel) { snap.gps = formatCoords(tel) || '—'; snap.packetTime = formatPacketTime(tel) || '—'; @@ -1942,10 +1970,16 @@ const t0 = Number(first.ts); const t1 = Number(last.ts); if (tNum <= t0) { - return { lat: Number(first.lat), lon: Number(first.lon), meta: first.meta, rssi: first.rssi }; + return { + lat: Number(first.lat), lon: Number(first.lon), meta: first.meta, rssi: first.rssi, + pointTs: t0, + }; } if (tNum >= t1) { - return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi }; + return { + lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi, + pointTs: t1, + }; } for (let i = 0; i < points.length - 1; i++) { const a = points[i]; @@ -1959,11 +1993,15 @@ lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * f, lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * f, meta: tNum - ta < tb - tNum ? a.meta : b.meta, - rssi: tNum - ta < tb - tNum ? a.rssi : b.rssi + rssi: tNum - ta < tb - tNum ? a.rssi : b.rssi, + pointTs: tNum - ta < tb - tNum ? ta : tb, }; } } - return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi }; + return { + lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi, + pointTs: t1, + }; } function overlapRange(txPts, rxPts) { diff --git a/server/static/radio-ui.js b/server/static/radio-ui.js index c0194ec..ce99848 100644 --- a/server/static/radio-ui.js +++ b/server/static/radio-ui.js @@ -61,6 +61,7 @@ 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.stats_at != null) snap.statsAt = Number(o.stats_at); if (o.fields && typeof o.fields === 'object') { for (const [k, v] of Object.entries(o.fields)) { if (!isKnownLabel(k)) snap.extraFields[k] = String(v);