Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js
T
sjgandClaude Opus 5 18b2d0efe6
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 9m12s
CI / frontend (pull_request) Successful in 5m52s
CI / reuse (pull_request) Successful in 6s
[feat](trx-rs): keep a station log, and a layout to work the bands from
The logbook of issue #54, in the shape the proposal settled on.

A new crate, trx-logbook, holds the contact, the ADIF reader and writer, the
file, and the rules for telling one contact from two.  ADIF because it is the
only thing the ecosystem reads: LoTW, eQSL, Club Log, QRZ and every other
logger take it and nothing else, so a log that cannot write .adi cannot be
uploaded, confirmed or moved.  The reader is forgiving in the ways real files
are irregular -- lowercase tags, CRLF, missing header, unknown fields, a
declared length that is the only thing ending a value -- and carries what it
does not model through to the export, so a round trip does not strip what
another program wrote.

The file is JSON Lines, appended one line per contact.  A log is the one
thing here that cannot be regenerated, and the bookmark store's whole-file
dump would rewrite megabytes to log one contact and lose all of them if the
power went halfway; an append costs the record being written and no more,
which a test tears a line in half to prove.  Edits append revisions, deletes
append tombstones, and the file compacts when the superseded outnumber the
live.

The panel is its own tab and stands in every layout.  An entry opens with six
fields and no more -- frequency, mode, rig name, time, and the callsign and
locator of whatever decode it was started from.  A report stays empty: an FT8
SNR is not what was sent.  Times come from the server, because the browser
may be a phone in another timezone, and the panel says so when the two
disagree by more than a second.  Worked-before answers as a callsign is
typed.

A decode is not a contact, so the Log button on an FT8 or APRS row opens an
entry and logs nothing by itself.

The ham layout is the fifth operator layout, opening on the logbook with the
radio controls around it, offered only where the rig can transmit.

Two bugs found on the way, both in code written here: a frequency of a whole
number of megahertz ending in a zero rendered as a tenth of itself, in Rust
and in TypeScript alike, because trimming trailing zeros from "20.000000"
walks back through the point.  The API also sits under /api/logbook rather
than /logbook, so it cannot shadow its own page the way /bookmarks does.

Closes #54

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 21:12:03 +02:00

315 lines
11 KiB
JavaScript

import {
aprsAgeText,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-REYSUJQ4.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/aprs.ts
var aprsWindow = window;
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
var showAprsHint = (message, durationMs) => {
hostCore.showHint(message, durationMs);
};
var aprsStatus = document.getElementById("aprs-status");
var aprsPacketsEl = document.getElementById("aprs-packets");
var aprsFilterInput = document.getElementById("aprs-filter");
var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
var aprsTotalCountEl = document.getElementById("aprs-total-count");
var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
var aprsFilterText = "";
var aprsPacketHistory = [];
var aprsBarDismissedAtMs = 0;
var aprsOnlyPos = false;
var aprsHideCrc = false;
var aprsCollapseDup = false;
var aprsTypeFilter = "all";
function currentAprsHistoryRetentionMs() {
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
}
function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
}
function scheduleAprsUi(key, job) {
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
aprsWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => {
renderAprsHistory();
});
}
function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => {
updateAprsBar();
});
}
function aprsDistanceText(pkt) {
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.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 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 aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() {
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = rigPackets[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);
}
async function copyAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
}
function renderAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => {
aprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyAprsCoords(text);
},
onLog: (call, gridsquare) => {
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
}
});
}
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();
updateAprsChipState();
}
function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
);
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">${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";
}
aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() {
resetAprsHistoryView();
};
aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
function resetAprsHistoryView() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs");
}
function pruneAprsHistoryView() {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
}
function plotAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
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();
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt) {
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
}
function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
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" });
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
normalized.reverse();
aprsPacketHistory = normalized.concat(aprsPacketHistory);
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
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 hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
})();
});
if (aprsOnlyPosBtn) {
aprsOnlyPosBtn.addEventListener("click", () => {
aprsOnlyPos = !aprsOnlyPos;
renderAprsHistory();
});
}
if (aprsHideCrcBtn) {
aprsHideCrcBtn.addEventListener("click", () => {
aprsHideCrc = !aprsHideCrc;
renderAprsHistory();
});
}
if (aprsCollapseDupBtn) {
aprsCollapseDupBtn.addEventListener("click", () => {
aprsCollapseDup = !aprsCollapseDup;
renderAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
aprsTypeFilter = type;
renderAprsHistory();
});
});
if (aprsFilterInput) {
aprsFilterInput.addEventListener("input", () => {
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
renderAprsHistory();
});
}
function onServerAprs(pkt) {
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
window.trxPluginRuntime.registerDecoder({
id: "aprs",
onMessage: onServerAprs,
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
}
});