// --- 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 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 aprsBarDismissedAtMs = 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) { aprsVisibleCountEl.textContent = `${visible.length} shown`; } 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); } 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 = `