// --- APRS Decoder Plugin (server-side decode) --- const aprsStatus = document.getElementById("aprs-status"); const aprsPacketsEl = document.getElementById("aprs-packets"); const aprsFilterInput = document.getElementById("aprs-filter"); const aprsBarOverlay = document.getElementById("aprs-bar-overlay"); const aprsPauseBtn = document.getElementById("aprs-pause-btn"); const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); const aprsTotalCountEl = document.getElementById("aprs-total-count"); const aprsVisibleCountEl = document.getElementById("aprs-visible-count"); const aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); const APRS_BAR_WINDOW_MS = 15 * 60 * 1000; let aprsFilterText = ""; let aprsPacketHistory = []; let aprsPaused = false; let aprsBufferedWhilePaused = 0; let aprsOnlyPos = false; let aprsHideCrc = false; let aprsCollapseDup = false; let aprsTypeFilter = "all"; function currentAprsHistoryRetentionMs() { return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000; } function pruneAprsPacketHistory() { const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); } function scheduleAprsUi(key, job) { if (typeof window.trxScheduleUiFrameJob === "function") { window.trxScheduleUiFrameJob(key, job); return; } job(); } function scheduleAprsHistoryRender() { scheduleAprsUi("aprs-history", () => renderAprsHistory()); } function scheduleAprsBarUpdate() { scheduleAprsUi("aprs-bar", () => updateAprsBar()); } function renderAprsInfo(pkt) { const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null; if (bytes && bytes.length > 0) { let out = ""; for (let i = 0; i < bytes.length; i++) { const b = bytes[i]; if (b >= 0x20 && b <= 0x7e) { const ch = String.fromCharCode(b); if (ch === "<") out += "<"; else if (ch === ">") out += ">"; else if (ch === "&") out += "&"; else if (ch === '"') out += """; else out += ch; } else { const hex = b.toString(16).toUpperCase().padStart(2, "0"); out += `0x${hex}`; } } return out; } const str = pkt.info || ""; let out = ""; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code >= 0x20 && code <= 0x7e) { const ch = str[i]; if (ch === "<") out += "<"; else if (ch === ">") out += ">"; else if (ch === "&") out += "&"; else if (ch === '"') out += """; else out += ch; } else { const hex = code.toString(16).toUpperCase().padStart(2, "0"); out += `0x${hex}`; } } return out; } function aprsPacketCategory(pkt) { const type = String(pkt.type || "").toLowerCase(); const info = String(pkt.info || "").toLowerCase(); if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position"; if (type.includes("message") || info.startsWith(":")) return "message"; if (type.includes("weather") || info.startsWith("_")) return "weather"; if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry"; return "other"; } function aprsCategoryLabel(category) { switch (category) { case "position": return "Position"; case "message": return "Message"; case "weather": return "Weather"; case "telemetry": return "Telemetry"; default: return "Other"; } } function aprsAgeText(tsMs) { if (!Number.isFinite(tsMs)) return "just now"; const deltaMs = Math.max(0, Date.now() - tsMs); const seconds = Math.round(deltaMs / 1000); if (seconds < 5) return "just now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); return `${hours}h ago`; } function aprsDistanceText(pkt) { if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return ""; const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon); if (!Number.isFinite(distKm)) return ""; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; } function aprsPacketSignature(pkt) { return [ pkt.srcCall || "", pkt.destCall || "", pkt.path || "", pkt.info || "", pkt.type || "", pkt.lat != null ? pkt.lat.toFixed(4) : "", pkt.lon != null ? pkt.lon.toFixed(4) : "", ].join("|"); } function aprsHexBytes(bytes) { if (!Array.isArray(bytes) || bytes.length === 0) return "--"; return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" "); } function aprsFilterMatch(pkt) { if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false; if (aprsHideCrc && !pkt.crcOk) return false; if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false; if (!aprsFilterText) return true; const haystack = [ pkt.srcCall, pkt.destCall, pkt.path, pkt.info, pkt.type, pkt.lat != null ? pkt.lat.toFixed(4) : "", pkt.lon != null ? pkt.lon.toFixed(4) : "", aprsPacketCategory(pkt), ] .filter(Boolean) .join(" ") .toUpperCase(); return haystack.includes(aprsFilterText); } function aprsVisiblePackets() { const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; return packets.filter(aprsFilterMatch); } function collapseAprsDuplicates(packets) { const seen = new Set(); const out = []; for (const pkt of packets) { const key = aprsPacketSignature(pkt); if (seen.has(key)) continue; seen.add(key); out.push(pkt); } return out; } function updateAprsSummary() { const visible = aprsVisiblePackets(); if (aprsTotalCountEl) { aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; } if (aprsVisibleCountEl) { let text = `${visible.length} shown`; if (aprsPaused && aprsBufferedWhilePaused > 0) { text += ` ยท ${aprsBufferedWhilePaused} buffered`; } aprsVisibleCountEl.textContent = text; } if (aprsLatestSeenEl) { const latest = aprsPacketHistory[0]; if (!latest) { aprsLatestSeenEl.textContent = "No packets yet"; } else { aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`; } } } function updateAprsChipState() { document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => { btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`); }); aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos); aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc); aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); if (aprsPauseBtn) { aprsPauseBtn.textContent = aprsPaused ? "Resume" : "Pause"; aprsPauseBtn.classList.toggle("active", aprsPaused); } } function renderAprsRow(pkt, isFresh) { const row = document.createElement("div"); row.className = "aprs-packet"; if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); if (isFresh) row.classList.add("aprs-packet-new"); const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); const age = aprsAgeText(pkt._tsMs); const category = aprsPacketCategory(pkt); const categoryLabel = aprsCategoryLabel(category); const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; const pathBadge = pkt.path ? `${escapeMapHtml(pkt.path)}` : ""; const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; let symbolHtml = ""; if (pkt.symbolTable && pkt.symbolCode) { const sheet = pkt.symbolTable === "/" ? 0 : 1; const code = pkt.symbolCode.charCodeAt(0) - 33; const col = code % 16; const row2 = Math.floor(code / 16); const bgX = -(col * 24); const bgY = -(row2 * 24); symbolHtml = ``; } const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; const distance = aprsDistanceText(pkt); const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; row.innerHTML = `
` + `${ts}` + symbolHtml + `${escapeMapHtml(pkt.srcCall)}` + `>${escapeMapHtml(pkt.destCall || "")}` + `${escapeMapHtml(categoryLabel)}` + pathBadge + crcBadge + `
` + `
` + `${escapeMapHtml(age)}` + (distance ? `${escapeMapHtml(distance)}` : "") + `${escapeMapHtml(pkt.type || "--")}` + `
` + `
` + `${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ` + `
` + `
` + `Details` + `
` + `Source${escapeMapHtml(pkt.srcCall || "--")}` + `Destination${escapeMapHtml(pkt.destCall || "--")}` + `Type${escapeMapHtml(pkt.type || "--")}` + `Path${escapeMapHtml(pkt.path || "--")}` + `Age${escapeMapHtml(age)}` + `CRC${pkt.crcOk ? "OK" : "Failed"}` + `Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}` + `Info${escapeMapHtml(pkt.info || "--")}` + `Info Bytes${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}` + `
` + `
`; row.querySelectorAll("[data-aprs-map]").forEach((el) => { el.addEventListener("click", (evt) => { evt.preventDefault(); const raw = String(el.dataset.aprsMap || ""); const [lat, lon] = raw.split(",").map(Number); if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) { window.navigateToAprsMap(lat, lon); } }); }); const copyBtn = row.querySelector("[data-aprs-copy]"); if (copyBtn) { copyBtn.addEventListener("click", async () => { const raw = String(copyBtn.dataset.aprsCopy || ""); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(raw); showHint("Coordinates copied", 1200); } } catch (_e) { showHint("Copy failed", 1500); } }); } return row; } function renderAprsHistory() { pruneAprsPacketHistory(); if (!aprsPacketsEl || aprsPaused) { updateAprsSummary(); updateAprsChipState(); return; } const visible = aprsVisiblePackets(); const fragment = document.createDocumentFragment(); for (let i = 0; i < visible.length; i++) { fragment.appendChild(renderAprsRow(visible[i], i === 0)); } aprsPacketsEl.replaceChildren(fragment); updateAprsSummary(); updateAprsChipState(); } function updateAprsBar() { if (!aprsBarOverlay) return; const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs); const frames = collapseAprsDuplicates(okFrames).slice(0, 8); if (!isPkt || frames.length === 0) { aprsBarOverlay.style.display = "none"; return; } let html = '
APRSLiveClearLast 15 minutes
'; for (const pkt of frames) { const ts = pkt._ts ? `${pkt._ts}` : ""; const call = `${escapeMapHtml(pkt.srcCall)}`; const dest = escapeMapHtml(pkt.destCall || ""); const info = escapeMapHtml(pkt.info || ""); const pin = pkt.lat != null && pkt.lon != null ? `` : ""; html += `
` + `
${ts}${pin}${call}>${dest}: ${info}
` + `
`; } aprsBarOverlay.innerHTML = html; aprsBarOverlay.style.display = "flex"; } window.updateAprsBar = updateAprsBar; window.clearAprsBar = function() { document.getElementById("aprs-clear-btn")?.click(); }; window.resetAprsHistoryView = function() { if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; aprsPacketHistory = []; aprsBufferedWhilePaused = 0; updateAprsBar(); renderAprsHistory(); if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs"); }; window.pruneAprsHistoryView = function() { pruneAprsPacketHistory(); updateAprsBar(); renderAprsHistory(); }; function addAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); aprsPacketHistory.unshift(pkt); pruneAprsPacketHistory(); if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt); } if (pkt.crcOk) scheduleAprsBarUpdate(); if (aprsPaused) { aprsBufferedWhilePaused += 1; updateAprsSummary(); updateAprsChipState(); return; } scheduleAprsHistoryRender(); } document.getElementById("aprs-clear-btn").addEventListener("click", async () => { try { await postPath("/clear_aprs_decode"); window.resetAprsHistoryView(); } catch (e) { console.error("APRS clear failed", e); } }); if (aprsPauseBtn) { aprsPauseBtn.addEventListener("click", () => { aprsPaused = !aprsPaused; if (!aprsPaused) { aprsBufferedWhilePaused = 0; renderAprsHistory(); } else { updateAprsSummary(); updateAprsChipState(); } }); } if (aprsOnlyPosBtn) { aprsOnlyPosBtn.addEventListener("click", () => { aprsOnlyPos = !aprsOnlyPos; renderAprsHistory(); }); } if (aprsHideCrcBtn) { aprsHideCrcBtn.addEventListener("click", () => { aprsHideCrc = !aprsHideCrc; renderAprsHistory(); }); } if (aprsCollapseDupBtn) { aprsCollapseDupBtn.addEventListener("click", () => { aprsCollapseDup = !aprsCollapseDup; renderAprsHistory(); }); } ["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { const btn = document.getElementById(`aprs-type-${type}`); if (!btn) return; btn.addEventListener("click", () => { aprsTypeFilter = type; renderAprsHistory(); }); }); if (aprsFilterInput) { aprsFilterInput.addEventListener("input", () => { aprsFilterText = aprsFilterInput.value.trim().toUpperCase(); renderAprsHistory(); }); } // --- Server-side APRS decode handler --- window.onServerAprs = function(pkt) { aprsStatus.textContent = aprsPaused ? "Paused" : "Receiving"; addAprsPacket({ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, srcCall: pkt.src_call, destCall: pkt.dest_call, path: pkt.path, info: pkt.info, info_bytes: pkt.info_bytes, type: pkt.packet_type, crcOk: pkt.crc_ok, ts_ms: pkt.ts_ms, lat: pkt.lat, lon: pkt.lon, symbolTable: pkt.symbol_table, symbolCode: pkt.symbol_code, }); }; renderAprsHistory();