refactor: convert APRS plugin to TypeScript

This commit is contained in:
sjg
2026-08-01 12:43:40 +02:00
parent d93f784aad
commit 074c67c7b9
7 changed files with 613 additions and 509 deletions
@@ -1,407 +1,419 @@
"use strict"; "use strict";
const aprsStatus = document.getElementById("aprs-status"); (() => {
const aprsPacketsEl = document.getElementById("aprs-packets"); // src/plugins/aprs.ts
const aprsFilterInput = document.getElementById("aprs-filter"); var aprsWindow = window;
const aprsBarOverlay = document.getElementById("aprs-bar-overlay"); var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); var showAprsHint = (message, durationMs) => {
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); aprsWindow.showHint?.(message, durationMs);
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); };
const aprsTotalCountEl = document.getElementById("aprs-total-count"); var aprsStatus = document.getElementById("aprs-status");
const aprsVisibleCountEl = document.getElementById("aprs-visible-count"); var aprsPacketsEl = document.getElementById("aprs-packets");
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); var aprsFilterInput = document.getElementById("aprs-filter");
const APRS_BAR_WINDOW_MS = 15 * 60 * 1e3; var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
let aprsFilterText = ""; var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
let aprsPacketHistory = []; var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
let aprsBarDismissedAtMs = 0; var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
let aprsOnlyPos = false; var aprsTotalCountEl = document.getElementById("aprs-total-count");
let aprsHideCrc = false; var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
let aprsCollapseDup = false; var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
let aprsTypeFilter = "all"; var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
function currentAprsHistoryRetentionMs() { var aprsFilterText = "";
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; var aprsPacketHistory = [];
} var aprsBarDismissedAtMs = 0;
function pruneAprsPacketHistory() { var aprsOnlyPos = false;
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); var aprsHideCrc = false;
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); var aprsCollapseDup = false;
} var aprsTypeFilter = "all";
function scheduleAprsUi(key, job) { function currentAprsHistoryRetentionMs() {
if (typeof window.trxScheduleUiFrameJob === "function") { return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
window.trxScheduleUiFrameJob(key, job);
return;
} }
job(); function pruneAprsPacketHistory() {
} const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
function scheduleAprsHistoryRender() { aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
scheduleAprsUi("aprs-history", () => renderAprsHistory()); }
} function scheduleAprsUi(key, job) {
function scheduleAprsBarUpdate() { if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
scheduleAprsUi("aprs-bar", () => updateAprsBar()); aprsWindow.trxScheduleUiFrameJob(key, job);
} return;
function renderAprsInfo(pkt) {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) {
let out2 = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b >= 32 && b <= 126) {
const ch = String.fromCharCode(b);
if (ch === "<") out2 += "&lt;";
else if (ch === ">") out2 += "&gt;";
else if (ch === "&") out2 += "&amp;";
else if (ch === '"') out2 += "&quot;";
else out2 += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out2 += `<span class="aprs-byte">0x${hex}</span>`;
}
} }
return out2; job();
} }
const str = pkt.info || ""; function scheduleAprsHistoryRender() {
let out = ""; scheduleAprsUi("aprs-history", () => {
for (let i = 0; i < str.length; i++) { renderAprsHistory();
const code = str.charCodeAt(i);
if (code >= 32 && code <= 126) {
const ch = str[i];
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
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 / 1e3);
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 * 1e3)} 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 = /* @__PURE__ */ 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 || (/* @__PURE__ */ 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 ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
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 = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
}
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span><span>&gt;${escapeMapHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeMapHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
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]"); function scheduleAprsBarUpdate() {
if (copyBtn) { scheduleAprsUi("aprs-bar", () => {
copyBtn.addEventListener("click", async () => { updateAprsBar();
const raw = String(copyBtn.dataset.aprsCopy || ""); });
try { }
if (navigator.clipboard?.writeText) { function renderAprsInfo(pkt) {
await navigator.clipboard.writeText(raw); const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
showHint("Coordinates copied", 1200); if (bytes && bytes.length > 0) {
let out2 = "";
for (const b of bytes) {
if (b >= 32 && b <= 126) {
const ch = String.fromCharCode(b);
if (ch === "<") out2 += "&lt;";
else if (ch === ">") out2 += "&gt;";
else if (ch === "&") out2 += "&amp;";
else if (ch === '"') out2 += "&quot;";
else out2 += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out2 += `<span class="aprs-byte">0x${hex}</span>`;
} }
} catch (_e) {
showHint("Copy failed", 1500);
} }
}); return out2;
}
const str = pkt.info || "";
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code >= 32 && code <= 126) {
const ch = str.charAt(i);
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
} }
return row; function aprsPacketCategory(pkt) {
} const type = (pkt.type ?? "").toLowerCase();
function renderAprsHistory() { const info = (pkt.info ?? "").toLowerCase();
pruneAprsPacketHistory(); if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (!aprsPacketsEl) { 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 (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1e3);
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 (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} 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) => 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 = /* @__PURE__ */ 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 || (/* @__PURE__ */ 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 ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) {
const symbol = escapeAprsHtml(pkt.symbolCode);
const table = pkt.symbolTable === "/" ? "Primary" : "Alternate";
symbolHtml = `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
}
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})();
});
}
return row;
}
function renderAprsHistory() {
pruneAprsPacketHistory();
if (!aprsPacketsEl) {
updateAprsSummary();
updateAprsChipState();
return;
}
const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (const [index, packet] of visible.entries()) {
fragment.appendChild(renderAprsRow(packet, index === 0));
}
aprsPacketsEl.replaceChildren(fragment);
updateAprsSummary(); updateAprsSummary();
updateAprsChipState(); updateAprsChipState();
return;
} }
const visible = aprsVisiblePackets(); function updateAprsBar() {
const fragment = document.createDocumentFragment(); if (!aprsBarOverlay) return;
for (let i = 0; i < visible.length; i++) { const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
fragment.appendChild(renderAprsRow(visible[i], i === 0)); const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
} const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
aprsPacketsEl.replaceChildren(fragment); const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
updateAprsSummary(); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
updateAprsChipState(); if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
} aprsBarOverlay.style.display = "none";
function updateAprsBar() { aprsBarOverlay.innerHTML = "";
if (!aprsBarOverlay) return; 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);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
return;
}
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>`;
for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
const dest = escapeMapHtml(pkt.destCall || "");
const info = escapeMapHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
}
aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex";
}
window.updateAprsBar = updateAprsBar;
window.clearAprsBar = function() {
window.resetAprsHistoryView();
};
window.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
window.resetAprsHistoryView = function() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
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();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt) {
return {
rig_id: pkt.rig_id || null,
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
};
}
window.onServerAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
} }
if (next.crcOk) hasCrcOk = true; let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>`;
normalized.push(next); for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
const dest = escapeAprsHtml(pkt.destCall || "");
const info = escapeAprsHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
}
aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex";
} }
normalized.reverse(); aprsWindow.updateAprsBar = updateAprsBar;
aprsPacketHistory = normalized.concat(aprsPacketHistory); aprsWindow.clearAprsBar = function() {
pruneAprsPacketHistory(); aprsWindow.resetAprsHistoryView?.();
if (hasCrcOk) scheduleAprsBarUpdate(); };
scheduleAprsHistoryRender(); aprsWindow.closeAprsBar = function() {
}; aprsBarDismissedAtMs = Date.now();
window.restoreAprsHistory = function(packets) { if (aprsBarOverlay) {
window.onServerAprsBatch(packets); aprsBarOverlay.style.display = "none";
}; aprsBarOverlay.innerHTML = "";
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => { }
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; };
try { aprsWindow.resetAprsHistoryView = function() {
await postPath("/clear_aprs_decode"); if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
window.resetAprsHistoryView(); aprsPacketHistory = [];
} catch (e) { updateAprsBar();
console.error("APRS history clear failed", e); renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs");
};
aprsWindow.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 && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
} }
}); function normalizeServerAprsPacket(pkt) {
if (aprsOnlyPosBtn) { return {
aprsOnlyPosBtn.addEventListener("click", () => { rig_id: pkt.rig_id || null,
aprsOnlyPos = !aprsOnlyPos; receiver: aprsWindow.getDecodeRigMeta?.() ?? null,
renderAprsHistory(); 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 ?? false,
ts_ms: pkt.ts_ms ?? null,
lat: pkt.lat ?? null,
lon: pkt.lon ?? null,
symbolTable: pkt.symbol_table ?? null,
symbolCode: pkt.symbol_code ?? null
};
}
aprsWindow.onServerAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
normalized.reverse();
aprsPacketHistory = normalized.concat(aprsPacketHistory);
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
};
aprsWindow.restoreAprsHistory = function(packets) {
aprsWindow.onServerAprsBatch?.(packets);
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aprsWindow.postPath?.("/clear_aprs_decode");
aprsWindow.resetAprsHistoryView?.();
} catch (e) {
console.error("APRS history clear failed", e);
}
})();
}); });
} if (aprsOnlyPosBtn) {
if (aprsHideCrcBtn) { aprsOnlyPosBtn.addEventListener("click", () => {
aprsHideCrcBtn.addEventListener("click", () => { aprsOnlyPos = !aprsOnlyPos;
aprsHideCrc = !aprsHideCrc; renderAprsHistory();
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) {
if (aprsCollapseDupBtn) { aprsFilterInput.addEventListener("input", () => {
aprsCollapseDupBtn.addEventListener("click", () => { aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
aprsCollapseDup = !aprsCollapseDup; renderAprsHistory();
renderAprsHistory(); });
}); }
} aprsWindow.onServerAprs = function(pkt) {
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { if (aprsStatus) aprsStatus.textContent = "Receiving";
const btn = document.getElementById(`aprs-type-${type}`); addAprsPacket(normalizeServerAprsPacket(pkt));
if (!btn) return; };
btn.addEventListener("click", () => { renderAprsHistory();
aprsTypeFilter = type; aprsWindow._trxDrainPendingDecode?.("aprs");
renderAprsHistory(); })();
});
});
if (aprsFilterInput) {
aprsFilterInput.addEventListener("input", () => {
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
renderAprsHistory();
});
}
window.onServerAprs = function(pkt) {
aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
};
renderAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
@@ -10,7 +10,7 @@ const pluginGroups = {
}; };
const loaded = /* @__PURE__ */ new Set(); const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map(); const loading = /* @__PURE__ */ new Map();
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js"]); const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js"]);
function loadLegacyScript(path) { function loadLegacyScript(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
@@ -2521,6 +2521,7 @@ body.map-fake-fullscreen-active {
word-break: break-all; word-break: break-all;
} }
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; } .aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
.aprs-symbol-local { border: 1px solid var(--border); border-radius: 4px; background: var(--surface-raised); color: var(--text); font: 700 0.8rem/22px ui-monospace, monospace; text-align: center; }
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; } .aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
.aprs-pos:hover { text-decoration: underline; } .aprs-pos:hover { text-decoration: underline; }
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: color-mix(in srgb, var(--card-bg) 84%, transparent) !important; color: var(--text) !important; box-shadow: 0 3px 14px rgba(0,0,0,0.45) !important; } .leaflet-popup-content-wrapper, .leaflet-popup-tip { background: color-mix(in srgb, var(--card-bg) 84%, transparent) !important; color: var(--text) !important; box-shadow: 0 3px 14px rgba(0,0,0,0.45) !important; }
@@ -22,7 +22,6 @@ await build({
"plugin-loader": path.join(sourceDir, "plugin-loader.ts"), "plugin-loader": path.join(sourceDir, "plugin-loader.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"), screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
aprs: path.join(sourceDir, "plugins", "aprs.js"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
sat: path.join(sourceDir, "plugins", "sat.js"), sat: path.join(sourceDir, "plugins", "sat.js"),
@@ -51,6 +50,7 @@ await build({
wefax: path.join(sourceDir, "plugins", "wefax.ts"), wefax: path.join(sourceDir, "plugins", "wefax.ts"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"), "background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
ais: path.join(sourceDir, "plugins", "ais.ts"), ais: path.join(sourceDir, "plugins", "ais.ts"),
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: true, bundle: true,
@@ -16,7 +16,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
const loaded = new Set<string>(); const loaded = new Set<string>();
const loading = new Map<string, Promise<void>>(); const loading = new Map<string, Promise<void>>();
const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js"]); const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js"]);
function loadLegacyScript(path: string): Promise<void> { function loadLegacyScript(path: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -2,10 +2,68 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export {};
type AprsCategory = "position" | "message" | "weather" | "telemetry" | "other";
type AprsTypeFilter = "all" | AprsCategory;
interface AprsPacket {
rig_id?: string | null;
receiver?: unknown;
srcCall?: string;
destCall?: string;
path?: string;
info?: string;
info_bytes?: number[];
type?: string;
crcOk?: boolean;
ts_ms?: number | null;
lat?: number | null;
lon?: number | null;
symbolTable?: string | null;
symbolCode?: string | null;
_tsMs?: number;
_ts?: string;
src_call?: string;
dest_call?: string;
packet_type?: string;
crc_ok?: boolean;
symbol_table?: string | null;
symbol_code?: string | null;
}
interface AprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
serverLat?: number | null;
serverLon?: number | null;
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
escapeMapHtml?: (input: string) => string;
navigateToAprsMap?: (lat: number, lon: number) => void;
showHint?: (message: string, durationMs: number) => void;
clearMapMarkersByType?: (type: string) => void;
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
getDecodeRigMeta?: () => unknown;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
updateAprsBar?: () => void;
clearAprsBar?: () => void;
closeAprsBar?: () => void;
resetAprsHistoryView?: () => void;
pruneAprsHistoryView?: () => void;
onServerAprsBatch?: (packets: AprsPacket[]) => void;
restoreAprsHistory?: (packets: AprsPacket[]) => void;
onServerAprs?: (packet: AprsPacket) => void;
_trxDrainPendingDecode?: (decoder: string) => void;
}
const aprsWindow = window as unknown as AprsBridge;
const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const showAprsHint = (message: string, durationMs: number): void => { aprsWindow.showHint?.(message, durationMs); };
// --- APRS Decoder Plugin (server-side decode) --- // --- APRS Decoder Plugin (server-side decode) ---
const aprsStatus = document.getElementById("aprs-status"); const aprsStatus = document.getElementById("aprs-status");
const aprsPacketsEl = document.getElementById("aprs-packets"); const aprsPacketsEl = document.getElementById("aprs-packets");
const aprsFilterInput = document.getElementById("aprs-filter"); const aprsFilterInput = document.getElementById("aprs-filter") as HTMLInputElement | null;
const aprsBarOverlay = document.getElementById("aprs-bar-overlay"); const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
@@ -15,46 +73,45 @@ const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000; const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
let aprsFilterText = ""; let aprsFilterText = "";
let aprsPacketHistory = []; let aprsPacketHistory: AprsPacket[] = [];
let aprsBarDismissedAtMs = 0; let aprsBarDismissedAtMs = 0;
let aprsOnlyPos = false; let aprsOnlyPos = false;
let aprsHideCrc = false; let aprsHideCrc = false;
let aprsCollapseDup = false; let aprsCollapseDup = false;
let aprsTypeFilter = "all"; let aprsTypeFilter: AprsTypeFilter = "all";
function currentAprsHistoryRetentionMs() { function currentAprsHistoryRetentionMs(): number {
return typeof window.getDecodeHistoryRetentionMs === "function" return typeof aprsWindow.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs() ? aprsWindow.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000; : 24 * 60 * 60 * 1000;
} }
function pruneAprsPacketHistory() { function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
} }
function scheduleAprsUi(key, job) { function scheduleAprsUi(key: string, job: () => void): void {
if (typeof window.trxScheduleUiFrameJob === "function") { if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job); aprsWindow.trxScheduleUiFrameJob(key, job);
return; return;
} }
job(); job();
} }
function scheduleAprsHistoryRender() { function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => renderAprsHistory()); scheduleAprsUi("aprs-history", () => { renderAprsHistory(); });
} }
function scheduleAprsBarUpdate() { function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => updateAprsBar()); scheduleAprsUi("aprs-bar", () => { updateAprsBar(); });
} }
function renderAprsInfo(pkt) { function renderAprsInfo(pkt: AprsPacket): string {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null; const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) { if (bytes && bytes.length > 0) {
let out = ""; let out = "";
for (let i = 0; i < bytes.length; i++) { for (const b of bytes) {
const b = bytes[i];
if (b >= 0x20 && b <= 0x7e) { if (b >= 0x20 && b <= 0x7e) {
const ch = String.fromCharCode(b); const ch = String.fromCharCode(b);
if (ch === "<") out += "&lt;"; if (ch === "<") out += "&lt;";
@@ -74,7 +131,7 @@ function renderAprsInfo(pkt) {
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i); const code = str.charCodeAt(i);
if (code >= 0x20 && code <= 0x7e) { if (code >= 0x20 && code <= 0x7e) {
const ch = str[i]; const ch = str.charAt(i);
if (ch === "<") out += "&lt;"; if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;"; else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;"; else if (ch === "&") out += "&amp;";
@@ -88,9 +145,9 @@ function renderAprsInfo(pkt) {
return out; return out;
} }
function aprsPacketCategory(pkt) { function aprsPacketCategory(pkt: AprsPacket): AprsCategory {
const type = String(pkt.type || "").toLowerCase(); const type = (pkt.type ?? "").toLowerCase();
const info = String(pkt.info || "").toLowerCase(); const info = (pkt.info ?? "").toLowerCase();
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position"; if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message"; if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather"; if (type.includes("weather") || info.startsWith("_")) return "weather";
@@ -98,7 +155,7 @@ function aprsPacketCategory(pkt) {
return "other"; return "other";
} }
function aprsCategoryLabel(category) { function aprsCategoryLabel(category: AprsCategory): string {
switch (category) { switch (category) {
case "position": return "Position"; case "position": return "Position";
case "message": return "Message"; case "message": return "Message";
@@ -108,8 +165,8 @@ function aprsCategoryLabel(category) {
} }
} }
function aprsAgeText(tsMs) { function aprsAgeText(tsMs: number | undefined): string {
if (!Number.isFinite(tsMs)) return "just now"; if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs); const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000); const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now"; if (seconds < 5) return "just now";
@@ -120,15 +177,15 @@ function aprsAgeText(tsMs) {
return `${hours}h ago`; return `${hours}h ago`;
} }
function aprsDistanceText(pkt) { function aprsDistanceText(pkt: AprsPacket): string {
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return ""; if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon); const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return ""; if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`; return `${distKm.toFixed(1)} km from TRX`;
} }
function aprsPacketSignature(pkt) { function aprsPacketSignature(pkt: AprsPacket): string {
return [ return [
pkt.srcCall || "", pkt.srcCall || "",
pkt.destCall || "", pkt.destCall || "",
@@ -140,12 +197,12 @@ function aprsPacketSignature(pkt) {
].join("|"); ].join("|");
} }
function aprsHexBytes(bytes) { function aprsHexBytes(bytes: number[] | undefined): string {
if (!Array.isArray(bytes) || bytes.length === 0) return "--"; if (!Array.isArray(bytes) || bytes.length === 0) return "--";
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" "); return bytes.map((b) => b.toString(16).toUpperCase().padStart(2, "0")).join(" ");
} }
function aprsFilterMatch(pkt) { function aprsFilterMatch(pkt: AprsPacket): boolean {
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false; if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (aprsHideCrc && !pkt.crcOk) return false; if (aprsHideCrc && !pkt.crcOk) return false;
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false; if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
@@ -166,14 +223,14 @@ function aprsFilterMatch(pkt) {
return haystack.includes(aprsFilterText); return haystack.includes(aprsFilterText);
} }
function aprsVisiblePackets() { function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function collapseAprsDuplicates(packets) { function collapseAprsDuplicates(packets: AprsPacket[]): AprsPacket[] {
const seen = new Set(); const seen = new Set();
const out = []; const out: AprsPacket[] = [];
for (const pkt of packets) { for (const pkt of packets) {
const key = aprsPacketSignature(pkt); const key = aprsPacketSignature(pkt);
if (seen.has(key)) continue; if (seen.has(key)) continue;
@@ -210,7 +267,7 @@ function updateAprsChipState() {
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
} }
function renderAprsRow(pkt, isFresh) { function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "aprs-packet"; row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
@@ -221,17 +278,13 @@ function renderAprsRow(pkt, isFresh) {
const category = aprsPacketCategory(pkt); const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category); const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : ""; const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>'; const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
let symbolHtml = ""; let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) { if (pkt.symbolTable && pkt.symbolCode) {
const sheet = pkt.symbolTable === "/" ? 0 : 1; const symbol = escapeAprsHtml(pkt.symbolCode);
const code = pkt.symbolCode.charCodeAt(0) - 33; const table = pkt.symbolTable === "/" ? "Primary" : "Alternate";
const col = code % 16; symbolHtml = `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
const row2 = Math.floor(code / 16);
const bgX = -(col * 24);
const bgY = -(row2 * 24);
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
} }
const posLink = pkt.lat != null && pkt.lon != null const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
@@ -243,19 +296,19 @@ function renderAprsRow(pkt, isFresh) {
`<div class="aprs-row-head">` + `<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` + `<span class="aprs-time">${ts}</span>` +
symbolHtml + symbolHtml +
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` + `<span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` + `<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
pathBadge + pathBadge +
crcBadge + crcBadge +
`</div>` + `</div>` +
`<div class="aprs-row-meta">` + `<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` + `<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
`</div>` + `</div>` +
`<div class="aprs-row-detail">` + `<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + `<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") + (posLink ? `<span>${posLink}</span>` : "") +
`</div>` + `</div>` +
`<div class="aprs-row-actions">` + `<div class="aprs-row-actions">` +
@@ -266,42 +319,43 @@ function renderAprsRow(pkt, isFresh) {
`<details class="aprs-details">` + `<details class="aprs-details">` +
`<summary>Details</summary>` + `<summary>Details</summary>` +
`<div class="aprs-details-grid">` + `<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` + `<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` + `<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` + `<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` + `<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` + `<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` + `<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` + `<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` + `<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` + `<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` + `</div>` +
`</details>`; `</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => { row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => { el.addEventListener("click", (evt) => {
evt.preventDefault(); evt.preventDefault();
const raw = String(el.dataset.aprsMap || ""); const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number); const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) { if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon); aprsWindow.navigateToAprsMap(lat, lon);
} }
}); });
}); });
const copyBtn = row.querySelector("[data-aprs-copy]"); const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) { if (copyBtn) {
copyBtn.addEventListener("click", async () => { copyBtn.addEventListener("click", () => { void (async () => {
const raw = String(copyBtn.dataset.aprsCopy || ""); const raw = copyBtn.dataset.aprsCopy ?? "";
try { try {
if (navigator.clipboard?.writeText) { const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
await navigator.clipboard.writeText(raw); if (clipboard) {
showHint("Coordinates copied", 1200); await clipboard.writeText(raw);
showAprsHint("Coordinates copied", 1200);
} }
} catch (_e) { } catch {
showHint("Copy failed", 1500); showAprsHint("Copy failed", 1500);
} }
}); })(); });
} }
return row; return row;
@@ -316,8 +370,8 @@ function renderAprsHistory() {
} }
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) { for (const [index, packet] of visible.entries()) {
fragment.appendChild(renderAprsRow(visible[i], i === 0)); fragment.appendChild(renderAprsRow(packet, index === 0));
} }
aprsPacketsEl.replaceChildren(fragment); aprsPacketsEl.replaceChildren(fragment);
updateAprsSummary(); updateAprsSummary();
@@ -326,9 +380,9 @@ function renderAprsHistory() {
function updateAprsBar() { function updateAprsBar() {
if (!aprsBarOverlay) return; if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs); const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
@@ -339,9 +393,9 @@ function updateAprsBar() {
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>'; let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>';
for (const pkt of frames) { for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : ""; const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`; const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
const dest = escapeMapHtml(pkt.destCall || ""); const dest = escapeAprsHtml(pkt.destCall || "");
const info = escapeMapHtml(pkt.info || ""); const info = escapeAprsHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null const pin = pkt.lat != null && pkt.lon != null
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
: ""; : "";
@@ -352,11 +406,11 @@ function updateAprsBar() {
aprsBarOverlay.innerHTML = html; aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex"; aprsBarOverlay.style.display = "flex";
} }
window.updateAprsBar = updateAprsBar; aprsWindow.updateAprsBar = updateAprsBar;
window.clearAprsBar = function() { aprsWindow.clearAprsBar = function() {
window.resetAprsHistoryView(); aprsWindow.resetAprsHistoryView?.();
}; };
window.closeAprsBar = function() { aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now(); aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) { if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none"; aprsBarOverlay.style.display = "none";
@@ -364,21 +418,21 @@ window.closeAprsBar = function() {
} }
}; };
window.resetAprsHistoryView = function() { aprsWindow.resetAprsHistoryView = function() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = []; aprsPacketHistory = [];
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs"); aprsWindow.clearMapMarkersByType?.("aprs");
}; };
window.pruneAprsHistoryView = function() { aprsWindow.pruneAprsHistoryView = function() {
pruneAprsPacketHistory(); pruneAprsPacketHistory();
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
}; };
function addAprsPacket(pkt) { function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -386,8 +440,8 @@ function addAprsPacket(pkt) {
aprsPacketHistory.unshift(pkt); aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt); aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
} }
if (pkt.crcOk) scheduleAprsBarUpdate(); if (pkt.crcOk) scheduleAprsBarUpdate();
@@ -395,37 +449,37 @@ function addAprsPacket(pkt) {
scheduleAprsHistoryRender(); scheduleAprsHistoryRender();
} }
function normalizeServerAprsPacket(pkt) { function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
return { return {
rig_id: pkt.rig_id || null, rig_id: pkt.rig_id || null,
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, receiver: aprsWindow.getDecodeRigMeta?.() ?? null,
srcCall: pkt.src_call, srcCall: pkt.src_call ?? "",
destCall: pkt.dest_call, destCall: pkt.dest_call ?? "",
path: pkt.path, path: pkt.path ?? "",
info: pkt.info, info: pkt.info ?? "",
info_bytes: pkt.info_bytes, info_bytes: pkt.info_bytes ?? [],
type: pkt.packet_type, type: pkt.packet_type ?? "",
crcOk: pkt.crc_ok, crcOk: pkt.crc_ok ?? false,
ts_ms: pkt.ts_ms, ts_ms: pkt.ts_ms ?? null,
lat: pkt.lat, lat: pkt.lat ?? null,
lon: pkt.lon, lon: pkt.lon ?? null,
symbolTable: pkt.symbol_table, symbolTable: pkt.symbol_table ?? null,
symbolCode: pkt.symbol_code, symbolCode: pkt.symbol_code ?? null,
}; };
} }
window.onServerAprsBatch = function(packets) { aprsWindow.onServerAprsBatch = function(packets: AprsPacket[]) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
aprsStatus.textContent = "Receiving"; if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = []; const normalized: AprsPacket[] = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && window.aprsMapAddStation) { if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next); aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
} }
if (next.crcOk) hasCrcOk = true; if (next.crcOk) hasCrcOk = true;
normalized.push(next); normalized.push(next);
@@ -437,19 +491,19 @@ window.onServerAprsBatch = function(packets) {
scheduleAprsHistoryRender(); scheduleAprsHistoryRender();
}; };
window.restoreAprsHistory = function(packets) { aprsWindow.restoreAprsHistory = function(packets: AprsPacket[]) {
window.onServerAprsBatch(packets); aprsWindow.onServerAprsBatch?.(packets);
}; };
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => {
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await postPath("/clear_aprs_decode"); await aprsWindow.postPath?.("/clear_aprs_decode");
window.resetAprsHistoryView(); aprsWindow.resetAprsHistoryView?.();
} catch (e) { } catch (e) {
console.error("APRS history clear failed", e); console.error("APRS history clear failed", e);
} }
}); })(); });
if (aprsOnlyPosBtn) { if (aprsOnlyPosBtn) {
aprsOnlyPosBtn.addEventListener("click", () => { aprsOnlyPosBtn.addEventListener("click", () => {
@@ -472,7 +526,7 @@ if (aprsCollapseDupBtn) {
}); });
} }
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { (["all", "position", "message", "weather", "telemetry", "other"] as const).forEach((type) => {
const btn = document.getElementById(`aprs-type-${type}`); const btn = document.getElementById(`aprs-type-${type}`);
if (!btn) return; if (!btn) return;
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
@@ -489,10 +543,10 @@ if (aprsFilterInput) {
} }
// --- Server-side APRS decode handler --- // --- Server-side APRS decode handler ---
window.onServerAprs = function(pkt) { aprsWindow.onServerAprs = function(pkt: AprsPacket) {
aprsStatus.textContent = "Receiving"; if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt)); addAprsPacket(normalizeServerAprsPacket(pkt));
}; };
renderAprsHistory(); renderAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs"); aprsWindow._trxDrainPendingDecode?.("aprs");
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
test("APRS entry normalizes positioned packets without remote symbol assets", async () => {
const forwarded = [];
const window = {
trxUi: { confirm: async () => true },
aprsMapAddStation: (...args) => { forwarded.push(args); },
};
const context = vm.createContext({
window,
document: { getElementById: () => null, querySelectorAll: () => [] },
navigator: {},
Date,
Number,
String,
Array,
Set,
Reflect,
console,
});
const source = await readFile(new URL("../../assets/web/generated/aprs.js", import.meta.url), "utf8");
assert.equal(source.includes("raw.githubusercontent.com"), false);
new vm.Script(source).runInContext(context);
window.onServerAprs({ src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5, symbol_table: "/", symbol_code: ">" });
assert.equal(forwarded.length, 1);
assert.equal(forwarded[0][0], "SP0ABC");
assert.equal(forwarded[0][1], 54.5);
assert.equal(forwarded[0][6].rig_id, null);
});