refactor: convert APRS plugin to TypeScript
This commit is contained in:
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
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 += "<";
|
|
||||||
else if (ch === ">") out2 += ">";
|
|
||||||
else if (ch === "&") out2 += "&";
|
|
||||||
else if (ch === '"') out2 += """;
|
|
||||||
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 += "<";
|
|
||||||
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 += `<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>>${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 += "<";
|
||||||
|
else if (ch === ">") out2 += ">";
|
||||||
|
else if (ch === "&") out2 += "&";
|
||||||
|
else if (ch === '"') out2 += """;
|
||||||
|
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 += "<";
|
||||||
|
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 += `<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>>${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">×</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">×</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) => {
|
||||||
|
|||||||
+169
-115
@@ -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("&", "&").replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
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 += "<";
|
if (ch === "<") out += "<";
|
||||||
@@ -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 += "<";
|
if (ch === "<") out += "<";
|
||||||
else if (ch === ">") out += ">";
|
else if (ch === ">") out += ">";
|
||||||
else if (ch === "&") out += "&";
|
else if (ch === "&") out += "&";
|
||||||
@@ -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>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
`<span>>${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">×</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">×</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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user