From 37987b27798af10ba2102fdfc848b62a95b63028 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 4 Aug 2026 23:12:02 +0200 Subject: [PATCH] [feat](trx-frontend-http): make the APRS list a log, and read the payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frame was a card five lines tall — timestamp, a meta line, the information field as it arrived on the air, three buttons, and a Details panel repeating the four things already on the row — so five frames filled the panel and the payload was left to be decoded by eye. A frame is one line now: time, station, type, and what the frame says. It opens in place for the path, the CRC, the raw field, its bytes and the actions. Twenty-one frames fit where five did. And the information field is read rather than echoed. Weather reports give temperature, wind, humidity and pressure; telemetry gives its sequence and channels; a message gives its addressee and text; a position gives the fix, course and speed, and the comment the station wrote. Anything that cannot be summarised falls back to the raw field, which is in the expanded view either way. HF APRS had a copy of the same forty lines of markup, differing by one badge, so both now build their rows from one function in the shared module — the CSS is shared between them and this would have broken it otherwise. Both headers lose their three summary cards for a line of counts beside the filters, which frees another fifth of the panel. Signed-off-by: Stan Grams --- .../assets/web/generated/aprs.js | 71 ++--- .../assets/web/generated/chunk-ROTCLFXS.js | 156 ----------- .../assets/web/generated/chunk-YAWVT7YC.js | 251 ++++++++++++++++++ .../assets/web/generated/hf-aprs.js | 70 ++--- .../assets/web/generated/map-core.js | 2 +- .../trx-frontend-http/assets/web/index.html | 44 +-- .../trx-frontend-http/assets/web/style.css | 85 +++++- .../frontend/src/plugins/aprs-shared.ts | 179 +++++++++++++ .../frontend/src/plugins/aprs.ts | 106 ++------ .../frontend/src/plugins/hf-aprs.ts | 108 ++------ .../frontend/tests/decode-flow.mjs | 68 +++++ 11 files changed, 661 insertions(+), 479 deletions(-) delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index fd3f223e..d8643464 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -1,13 +1,10 @@ import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol -} from "./chunk-ROTCLFXS.js"; + renderAprsPacketRow +} from "./chunk-YAWVT7YC.js"; import { hostCore, hostState @@ -114,51 +111,27 @@ function updateAprsChipState() { 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 ? `${escapeAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; - const distance = aprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - row.innerHTML = `
${ts}` + symbolHtml + `${escapeAprsHtml(pkt.srcCall ?? "")}>${escapeAprsHtml(pkt.destCall || "")}${escapeAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeAprsHtml(age)}` + (distance ? `${escapeAprsHtml(distance)}` : "") + `${escapeAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeAprsHtml(pkt.srcCall || "--")}Destination${escapeAprsHtml(pkt.destCall || "--")}Type${escapeAprsHtml(pkt.type || "--")}Path${escapeAprsHtml(pkt.path || "--")}Age${escapeAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeAprsHtml(pkt.info || "--")}Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; - 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); - } - })(); - }); +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); } - return row; +} +function renderAprsRow(pkt, isFresh) { + return renderAprsPacketRow(pkt, { + fresh: isFresh, + distance: aprsDistanceText(pkt), + onMap: (lat, lon) => { + aprsWindow.navigateToAprsMap?.(lat, lon); + }, + onCopy: (text) => { + void copyAprsCoords(text); + } + }); } function renderAprsHistory() { pruneAprsPacketHistory(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js deleted file mode 100644 index 30e308a1..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js +++ /dev/null @@ -1,156 +0,0 @@ -// src/plugins/aprs-shared.ts -function aprsPacketCategory(packet) { - const type = (packet.type ?? "").toLowerCase(); - const info = (packet.info ?? "").toLowerCase(); - if (packet.lat != null && packet.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(timestampMs) { - if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now"; - const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3); - if (seconds < 5) return "just now"; - if (seconds < 60) return `${String(seconds)}s ago`; - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${String(minutes)}m ago`; - return `${String(Math.round(minutes / 60))}h ago`; -} -function aprsPacketSignature(packet) { - return [ - packet.srcCall ?? "", - packet.destCall ?? "", - packet.path ?? "", - packet.info ?? "", - packet.type ?? "", - packet.lat?.toFixed(4) ?? "", - packet.lon?.toFixed(4) ?? "" - ].join("|"); -} -function collapseAprsDuplicates(packets) { - const seen = /* @__PURE__ */ new Set(); - return packets.filter((packet) => { - const signature = aprsPacketSignature(packet); - if (seen.has(signature)) return false; - seen.add(signature); - return true; - }); -} -function aprsHexBytes(bytes) { - if (!bytes?.length) return "--"; - return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" "); -} -function renderAprsInfo(packet) { - if (packet.info_bytes?.length) { - return packet.info_bytes.map((byte) => renderAprsByte(byte)).join(""); - } - return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join(""); -} -function renderAprsByte(byte) { - return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; -} -function renderAprsCharacter(character) { - const code = character.charCodeAt(0); - return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; -} -function escapeAprsCharacter(character) { - if (character === "<") return "<"; - if (character === ">") return ">"; - if (character === "&") return "&"; - if (character === '"') return """; - return character; -} -var APRS_SPRITE_COLUMNS = 16; -var APRS_SPRITE_CELL_PX = 24; -var APRS_SPRITE_FIRST_CODE = 33; -var APRS_SPRITE_LAST_CODE = 126; -function aprsSpriteOffset(code) { - if (code.length !== 1) return null; - const point = code.charCodeAt(0); - if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null; - const index = point - APRS_SPRITE_FIRST_CODE; - const column = index % APRS_SPRITE_COLUMNS; - const row = Math.floor(index / APRS_SPRITE_COLUMNS); - return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`; -} -function aprsSymbolSprite(symbolTable, symbolCode) { - if (!symbolTable || !symbolCode) return null; - const symbolOffset = aprsSpriteOffset(symbolCode); - if (!symbolOffset) return null; - if (symbolTable === "/") { - return { - className: "aprs-symbol-primary", - backgroundPosition: symbolOffset, - label: `Primary APRS symbol ${symbolTable}${symbolCode}` - }; - } - if (symbolTable === "\\") { - return { - className: "aprs-symbol-alternate", - backgroundPosition: symbolOffset, - label: `Alternate APRS symbol ${symbolTable}${symbolCode}` - }; - } - const overlayOffset = aprsSpriteOffset(symbolTable); - if (!overlayOffset) return null; - return { - className: "aprs-symbol-overlaid", - backgroundPosition: `${overlayOffset}, ${symbolOffset}`, - label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}` - }; -} -function renderLocalAprsSymbol(packet, escapeHtml) { - if (!packet.symbolTable || !packet.symbolCode) return ""; - const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode); - if (sprite) { - return ``; - } - const symbol = escapeHtml(packet.symbolCode); - const table = packet.symbolTable === "/" ? "Primary" : "Alternate"; - return `${symbol}`; -} -function normalizeAprsPacket(packet, receiver) { - return { - rig_id: packet.rig_id || null, - receiver, - srcCall: packet.src_call ?? "", - destCall: packet.dest_call ?? "", - path: packet.path ?? "", - info: packet.info ?? "", - info_bytes: packet.info_bytes ?? [], - type: packet.packet_type ?? "", - crcOk: packet.crc_ok ?? false, - ts_ms: packet.ts_ms ?? null, - lat: packet.lat ?? null, - lon: packet.lon ?? null, - symbolTable: packet.symbol_table ?? null, - symbolCode: packet.symbol_code ?? null - }; -} - -export { - aprsPacketCategory, - aprsCategoryLabel, - aprsAgeText, - collapseAprsDuplicates, - aprsHexBytes, - renderAprsInfo, - aprsSymbolSprite, - renderLocalAprsSymbol, - normalizeAprsPacket -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js new file mode 100644 index 00000000..3d8ea2e7 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js @@ -0,0 +1,251 @@ +// src/plugins/aprs-shared.ts +function escapeAprsHtml(value) { + return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function aprsPacketCategory(packet) { + const type = (packet.type ?? "").toLowerCase(); + const info = (packet.info ?? "").toLowerCase(); + if (packet.lat != null && packet.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(timestampMs) { + if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now"; + const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${String(seconds)}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + return `${String(Math.round(minutes / 60))}h ago`; +} +function aprsPacketSignature(packet) { + return [ + packet.srcCall ?? "", + packet.destCall ?? "", + packet.path ?? "", + packet.info ?? "", + packet.type ?? "", + packet.lat?.toFixed(4) ?? "", + packet.lon?.toFixed(4) ?? "" + ].join("|"); +} +function collapseAprsDuplicates(packets) { + const seen = /* @__PURE__ */ new Set(); + return packets.filter((packet) => { + const signature = aprsPacketSignature(packet); + if (seen.has(signature)) return false; + seen.add(signature); + return true; + }); +} +function aprsHexBytes(bytes) { + if (!bytes?.length) return "--"; + return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" "); +} +function renderAprsInfo(packet) { + if (packet.info_bytes?.length) { + return packet.info_bytes.map((byte) => renderAprsByte(byte)).join(""); + } + return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join(""); +} +function renderAprsByte(byte) { + return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; +} +function renderAprsCharacter(character) { + const code = character.charCodeAt(0); + return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; +} +function escapeAprsCharacter(character) { + if (character === "<") return "<"; + if (character === ">") return ">"; + if (character === "&") return "&"; + if (character === '"') return """; + return character; +} +var APRS_SPRITE_COLUMNS = 16; +var APRS_SPRITE_CELL_PX = 24; +var APRS_SPRITE_FIRST_CODE = 33; +var APRS_SPRITE_LAST_CODE = 126; +function aprsSpriteOffset(code) { + if (code.length !== 1) return null; + const point = code.charCodeAt(0); + if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null; + const index = point - APRS_SPRITE_FIRST_CODE; + const column = index % APRS_SPRITE_COLUMNS; + const row = Math.floor(index / APRS_SPRITE_COLUMNS); + return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`; +} +function aprsSymbolSprite(symbolTable, symbolCode) { + if (!symbolTable || !symbolCode) return null; + const symbolOffset = aprsSpriteOffset(symbolCode); + if (!symbolOffset) return null; + if (symbolTable === "/") { + return { + className: "aprs-symbol-primary", + backgroundPosition: symbolOffset, + label: `Primary APRS symbol ${symbolTable}${symbolCode}` + }; + } + if (symbolTable === "\\") { + return { + className: "aprs-symbol-alternate", + backgroundPosition: symbolOffset, + label: `Alternate APRS symbol ${symbolTable}${symbolCode}` + }; + } + const overlayOffset = aprsSpriteOffset(symbolTable); + if (!overlayOffset) return null; + return { + className: "aprs-symbol-overlaid", + backgroundPosition: `${overlayOffset}, ${symbolOffset}`, + label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}` + }; +} +function renderLocalAprsSymbol(packet, escapeHtml) { + if (!packet.symbolTable || !packet.symbolCode) return ""; + const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode); + if (sprite) { + return ``; + } + const symbol = escapeHtml(packet.symbolCode); + const table = packet.symbolTable === "/" ? "Primary" : "Alternate"; + return `${symbol}`; +} +function normalizeAprsPacket(packet, receiver) { + return { + rig_id: packet.rig_id || null, + receiver, + srcCall: packet.src_call ?? "", + destCall: packet.dest_call ?? "", + path: packet.path ?? "", + info: packet.info ?? "", + info_bytes: packet.info_bytes ?? [], + type: packet.packet_type ?? "", + crcOk: packet.crc_ok ?? false, + ts_ms: packet.ts_ms ?? null, + lat: packet.lat ?? null, + lon: packet.lon ?? null, + symbolTable: packet.symbol_table ?? null, + symbolCode: packet.symbol_code ?? null + }; +} +function fahrenheitToCelsius(fahrenheit) { + return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10; +} +function summarizeAprsWeather(info) { + const parts = []; + const temperature = /t(-?\d{2,3})/.exec(info); + if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`); + const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info); + if (wind) { + const gust = /g(\d{3})/.exec(info); + const knots = Number(wind[2]); + parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`); + } + const humidity = /h(\d{2})/.exec(info); + if (humidity) { + const value = Number(humidity[1]); + parts.push(`${value === 0 ? 100 : value}% RH`); + } + const pressure = /b(\d{5})/.exec(info); + if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`); + const rain = /r(\d{3})/.exec(info); + if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`); + return parts.length ? parts.join(" · ") : null; +} +function summarizeAprsTelemetry(info) { + const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim()); + if (!match?.[2]) return null; + const channels = match[2].split(",").filter((value) => value.length > 0); + const bits = match[3] ? ` · bits ${match[3]}` : ""; + return `#${match[1]} · ${channels.join(" ")}${bits}`; +} +function summarizeAprsMessage(info) { + const match = /^:([^:]{9}):(.*)$/.exec(info); + if (!match?.[1] || match[2] == null) return null; + const addressee = match[1].trim(); + const text = match[2].replace(/\{\d+\s*$/, "").trim(); + return `→ ${addressee}: ${text}`; +} +function summarizeAprsCourseSpeed(info) { + const match = /(\d{3})\/(\d{3})/.exec(info); + if (!match) return null; + const knots = Number(match[2]); + if (knots === 0) return null; + return `${Number(match[1])}° ${knots} kt`; +} +function summarizeAprsPayload(packet) { + const info = packet.info ?? ""; + if (!info) return null; + const category = aprsPacketCategory(packet); + if (category === "message") return summarizeAprsMessage(info); + if (category === "weather") return summarizeAprsWeather(info); + if (category === "telemetry") return summarizeAprsTelemetry(info); + if (category === "position") { + const parts = []; + if (packet.lat != null && packet.lon != null) { + parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`); + } + const courseSpeed = summarizeAprsCourseSpeed(info); + if (courseSpeed) parts.push(courseSpeed); + const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim(); + if (comment && comment.length <= 60) parts.push(comment); + return parts.length ? parts.join(" · ") : null; + } + const text = info.replace(/^[>;${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ
`; + row.querySelectorAll("[data-aprs-map]").forEach((element) => { + element.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number); + if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon); + }); + }); + const copyButton = row.querySelector("[data-aprs-copy]"); + if (copyButton) { + copyButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton); + }); + } + return row; +} + +export { + aprsPacketCategory, + aprsAgeText, + collapseAprsDuplicates, + aprsSymbolSprite, + normalizeAprsPacket, + renderAprsPacketRow +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index dc6edd25..8ac8bbed 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -1,13 +1,10 @@ import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol -} from "./chunk-ROTCLFXS.js"; + renderAprsPacketRow +} from "./chunk-YAWVT7YC.js"; import { hostCore, hostState @@ -15,7 +12,6 @@ import { // src/plugins/hf-aprs.ts var hfAprsWindow = window; -var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input); var hfAprsStatus = document.getElementById("hf-aprs-status"); var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); @@ -102,51 +98,27 @@ function updateHfAprsChipState() { hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup); } function renderHfAprsRow(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 ? `${escapeHfAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const hfBadge = 'HF'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; - const distance = hfAprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - row.innerHTML = `
${ts}` + hfBadge + symbolHtml + `${escapeHfAprsHtml(pkt.srcCall ?? "")}>${escapeHfAprsHtml(pkt.destCall || "")}${escapeHfAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeHfAprsHtml(age)}` + (distance ? `${escapeHfAprsHtml(distance)}` : "") + `${escapeHfAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeHfAprsHtml(pkt.srcCall || "--")}Destination${escapeHfAprsHtml(pkt.destCall || "--")}Type${escapeHfAprsHtml(pkt.type || "--")}Path${escapeHfAprsHtml(pkt.path || "--")}Age${escapeHfAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeHfAprsHtml(pkt.info || "--")}Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; - 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 (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { - hfAprsWindow.navigateToAprsMap(lat, lon); - } - }); + return renderAprsPacketRow(pkt, { + fresh: isFresh, + badge: "HF", + distance: hfAprsDistanceText(pkt), + onMap: (lat, lon) => { + hfAprsWindow.navigateToAprsMap?.(lat, lon); + }, + onCopy: (text) => { + void copyHfAprsCoords(text); + } }); - 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); - hostCore.showHint("Coordinates copied", 1200); - } - } catch { - hostCore.showHint("Copy failed", 1500); - } - })(); - }); +} +async function copyHfAprsCoords(text) { + try { + const clipboard = Reflect.get(navigator, "clipboard"); + if (!clipboard) return; + await clipboard.writeText(text); + hostCore.showHint("Coordinates copied", 1200); + } catch { + hostCore.showHint("Copy failed", 1500); } - return row; } function renderHfAprsHistory() { pruneHfAprsPacketHistory(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index ca02c8cb..759ebf1b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,6 +1,6 @@ import { aprsSymbolSprite -} from "./chunk-ROTCLFXS.js"; +} from "./chunk-YAWVT7YC.js"; // src/map-core.ts function mapEl(id) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 977cd9a6..600d963e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -722,20 +722,6 @@ SPDX-License-Identifier: GPL-2.0-or-later Waiting for server decode -
-
- Frames - 0 total -
-
- Visible - 0 shown -
-
- Latest - No packets yet -
-
@@ -743,11 +729,15 @@ SPDX-License-Identifier: GPL-2.0-or-later -
-
+ + + 0 shown + 0 total + No packets yet +
@@ -757,20 +747,6 @@ SPDX-License-Identifier: GPL-2.0-or-later Waiting for server decode -
-
- Frames - 0 total -
-
- Visible - 0 shown -
-
- Latest - No packets yet -
-
@@ -778,11 +754,15 @@ SPDX-License-Identifier: GPL-2.0-or-later -
-
+ + + 0 shown + 0 total + No packets yet +
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index adb8716f..7cda2aac 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -2844,34 +2844,93 @@ body.map-fake-fullscreen-active { background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } -.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; } +/* One line per frame, opening in place. A frame used to be a card five lines + tall — timestamp, meta, raw information field, three buttons, a Details + panel repeating the row — so five of them filled the panel. */ +.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; border-bottom: 1px solid var(--border); line-height: 1.35; } .aprs-packet:last-child { border-bottom: none; } +.aprs-line { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.28rem 0.55rem; + cursor: pointer; + list-style: none; + white-space: nowrap; +} +.aprs-line::-webkit-details-marker { display: none; } +.aprs-line:hover { + background: color-mix(in srgb, var(--btn-bg) 45%, transparent); +} +.aprs-packet[open] > .aprs-line { + background: color-mix(in srgb, var(--accent-green) 8%, transparent); +} +/* The summary takes the leftover width and ellipsises: a frame is one line + whatever its payload, so the column of times and callsigns stays readable. */ +.aprs-line-summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} +.aprs-line-distance { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 0.75rem; +} +.aprs-expanded { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.1rem 0.55rem 0.55rem 2.6rem; +} +.aprs-expanded-meta { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + font-size: 0.75rem; + color: var(--text-muted); +} +.aprs-expanded-raw, +.aprs-expanded-bytes { + word-break: break-all; + font-size: 0.78rem; + color: var(--text); +} +.aprs-expanded-bytes { + color: var(--text-muted); + font-size: 0.72rem; +} .aprs-packet-new { animation: aprs-row-flash 1.2s ease; } .aprs-packet-crc { opacity: 0.6; } -.aprs-row-head, -.aprs-row-meta, -.aprs-row-detail, .aprs-row-actions { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; } -.aprs-row-head + .aprs-row-meta, -.aprs-row-meta + .aprs-row-detail, -.aprs-row-detail + .aprs-row-actions { - margin-top: 0.2rem; -} -.aprs-row-detail { +/* Counts sit with the filters rather than in three cards of their own: they + are one short line of text, and they were taking a fifth of the panel. */ +.aprs-counts { + display: inline-flex; + align-items: center; + gap: 0.6rem; + margin-left: auto; + font-size: 0.72rem; color: var(--text-muted); - font-size: 0.78rem; + white-space: nowrap; } -.aprs-row-actions { - margin-top: 0.28rem; +.aprs-filter-sep { + width: 1px; + align-self: stretch; + min-height: 1.2rem; + margin-inline: 0.15rem; + background: color-mix(in srgb, var(--border-light) 65%, transparent); } .aprs-badge { display: inline-flex; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts index e9efafce..cf7c00d2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts @@ -30,6 +30,14 @@ export interface AprsPacket { symbol_code?: string | null; } +function escapeAprsHtml(value: string): string { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + export function aprsPacketCategory(packet: AprsPacket): AprsCategory { const type = (packet.type ?? "").toLowerCase(); const info = (packet.info ?? "").toLowerCase(); @@ -205,3 +213,174 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs symbolCode: packet.symbol_code ?? null, }; } + +// ── Payload summaries ────────────────────────────────────────────────────── +// APRS packs its meaning into the information field with a set of one-character +// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The +// list showed that field as it arrives on the air, so reading a weather report +// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line +// of plain text for the common types and leave the raw field to the expanded +// view, which is still the authority when a summary cannot be made. + +/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */ +function fahrenheitToCelsius(fahrenheit: number): number { + return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10; +} + +/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */ +function summarizeAprsWeather(info: string): string | null { + const parts: string[] = []; + const temperature = /t(-?\d{2,3})/.exec(info); + if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`); + const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info); + if (wind) { + const gust = /g(\d{3})/.exec(info); + const knots = Number(wind[2]); + parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`); + } + const humidity = /h(\d{2})/.exec(info); + if (humidity) { + const value = Number(humidity[1]); + parts.push(`${value === 0 ? 100 : value}% RH`); + } + const pressure = /b(\d{5})/.exec(info); + if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`); + const rain = /r(\d{3})/.exec(info); + if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`); + return parts.length ? parts.join(" · ") : null; +} + +/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */ +function summarizeAprsTelemetry(info: string): string | null { + const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim()); + if (!match?.[2]) return null; + const channels = match[2].split(",").filter((value) => value.length > 0); + const bits = match[3] ? ` · bits ${match[3]}` : ""; + return `#${match[1]} · ${channels.join(" ")}${bits}`; +} + +/** `:DEST :text{01` → addressed message text. */ +function summarizeAprsMessage(info: string): string | null { + const match = /^:([^:]{9}):(.*)$/.exec(info); + if (!match?.[1] || match[2] == null) return null; + const addressee = match[1].trim(); + const text = match[2].replace(/\{\d+\s*$/, "").trim(); + return `→ ${addressee}: ${text}`; +} + +/** Course/speed appended to a position, as `088/036`. */ +function summarizeAprsCourseSpeed(info: string): string | null { + const match = /(\d{3})\/(\d{3})/.exec(info); + if (!match) return null; + const knots = Number(match[2]); + if (knots === 0) return null; + return `${Number(match[1])}° ${knots} kt`; +} + +/** Whatever a frame is worth saying in one line, or null to fall back to raw. */ +export function summarizeAprsPayload(packet: AprsPacket): string | null { + const info = packet.info ?? ""; + if (!info) return null; + const category = aprsPacketCategory(packet); + if (category === "message") return summarizeAprsMessage(info); + if (category === "weather") return summarizeAprsWeather(info); + if (category === "telemetry") return summarizeAprsTelemetry(info); + if (category === "position") { + const parts: string[] = []; + if (packet.lat != null && packet.lon != null) { + parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`); + } + const courseSpeed = summarizeAprsCourseSpeed(info); + if (courseSpeed) parts.push(courseSpeed); + // Whatever the station wrote after the position report. + const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim(); + if (comment && comment.length <= 60) parts.push(comment); + return parts.length ? parts.join(" · ") : null; + } + // Status and anything else: the text it carries, minus its type character. + const text = info.replace(/^[>; void; + /** Puts a frame's coordinates on the clipboard. */ + onCopy?: (text: string, button: HTMLElement) => void; +} + +export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement { + const row = document.createElement("details"); + row.className = "aprs-packet"; + if (!packet.crcOk) row.classList.add("aprs-packet-crc"); + if (options.fresh) row.classList.add("aprs-packet-new"); + + const time = packet._ts + || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const category = aprsPacketCategory(packet); + const summary = summarizeAprsPayload(packet); + const hasPosition = packet.lat != null && packet.lon != null; + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; + + row.innerHTML = + `` + + `${escapeAprsHtml(time)}` + + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + + renderLocalAprsSymbol(packet, escapeAprsHtml) + + `${escapeAprsHtml(packet.srcCall ?? "")}` + + `` + + `${escapeAprsHtml(aprsCategoryLabel(category))}` + + `${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + + (packet.crcOk ? "" : 'CRC') + + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + + `` + + `
` + + `
` + + `>${escapeAprsHtml(packet.destCall || "--")}` + + `${escapeAprsHtml(packet.path || "no path")}` + + `${escapeAprsHtml(aprsAgeText(packet._tsMs))}` + + `CRC ${packet.crcOk ? "ok" : "failed"}` + + (hasPosition + ? `` + + `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` + : "") + + `
` + + `
${renderAprsInfo(packet)}
` + + (packet.info_bytes?.length + ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` + : "") + + `
` + + (hasPosition ? `` : "") + + (hasPosition ? `` : "") + + `QRZ` + + `
` + + `
`; + + row.querySelectorAll("[data-aprs-map]").forEach((element) => { + element.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number); + if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number); + }); + }); + const copyButton = row.querySelector("[data-aprs-copy]"); + if (copyButton) { + copyButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton); + }); + } + return row; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index b306cbc3..45e55674 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js"; import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol, + renderAprsPacketRow, type AprsPacket, type AprsTypeFilter, } from "./aprs-shared"; @@ -141,93 +138,24 @@ function updateAprsChipState() { aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); } -function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - - const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null - ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` - : ""; - const distance = aprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - - row.innerHTML = - `
` + - `${ts}` + - symbolHtml + - `${escapeAprsHtml(pkt.srcCall ?? "")}` + - `>${escapeAprsHtml(pkt.destCall || "")}` + - `${escapeAprsHtml(categoryLabel)}` + - pathBadge + - crcBadge + - `
` + - `
` + - `${escapeAprsHtml(age)}` + - (distance ? `${escapeAprsHtml(distance)}` : "") + - `${escapeAprsHtml(pkt.type || "--")}` + - `
` + - `
` + - `${renderAprsInfo(pkt)}` + - (posLink ? `${posLink}` : "") + - `
` + - `
` + - (pkt.lat != null && pkt.lon != null ? `` : "") + - (pkt.lat != null && pkt.lon != null ? `` : "") + - `QRZ` + - `
` + - `
` + - `Details` + - `
` + - `Source${escapeAprsHtml(pkt.srcCall || "--")}` + - `Destination${escapeAprsHtml(pkt.destCall || "--")}` + - `Type${escapeAprsHtml(pkt.type || "--")}` + - `Path${escapeAprsHtml(pkt.path || "--")}` + - `Age${escapeAprsHtml(age)}` + - `CRC${pkt.crcOk ? "OK" : "Failed"}` + - `Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}` + - `Info${escapeAprsHtml(pkt.info || "--")}` + - `Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}` + - `
` + - `
`; - - 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") as Clipboard | undefined; - if (clipboard) { - await clipboard.writeText(raw); - showAprsHint("Coordinates copied", 1200); - } - } catch { - showAprsHint("Copy failed", 1500); - } - })(); }); +async function copyAprsCoords(text: string): Promise { + try { + const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; + if (!clipboard) return; + await clipboard.writeText(text); + showAprsHint("Coordinates copied", 1200); + } catch { + showAprsHint("Copy failed", 1500); } +} - return row; +function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { + return renderAprsPacketRow(pkt, { + fresh: isFresh, + distance: aprsDistanceText(pkt), + onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); }, + onCopy: (text) => { void copyAprsCoords(text); }, + }); } function renderAprsHistory() { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts index 3ee88258..3192fe2e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts @@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js"; import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol, + renderAprsPacketRow, type AprsPacket, type AprsTypeFilter, } from "./aprs-shared"; @@ -27,7 +24,6 @@ interface HfAprsBridge { trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; } const hfAprsWindow = window as unknown as HfAprsBridge; -const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input); // --- HF APRS Decoder Plugin (server-side decode, 300 baud) --- const hfAprsStatus = document.getElementById("hf-aprs-status"); @@ -128,95 +124,27 @@ function updateHfAprsChipState() { hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup); } +// HF traffic goes in the same row as VHF, marked with the band it came in on. +// This was a second copy of the same forty lines of markup. function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - - const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeHfAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const hfBadge = 'HF'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null - ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` - : ""; - const distance = hfAprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - - row.innerHTML = - `
` + - `${ts}` + - hfBadge + - symbolHtml + - `${escapeHfAprsHtml(pkt.srcCall ?? "")}` + - `>${escapeHfAprsHtml(pkt.destCall || "")}` + - `${escapeHfAprsHtml(categoryLabel)}` + - pathBadge + - crcBadge + - `
` + - `
` + - `${escapeHfAprsHtml(age)}` + - (distance ? `${escapeHfAprsHtml(distance)}` : "") + - `${escapeHfAprsHtml(pkt.type || "--")}` + - `
` + - `
` + - `${renderAprsInfo(pkt)}` + - (posLink ? `${posLink}` : "") + - `
` + - `
` + - (pkt.lat != null && pkt.lon != null ? `` : "") + - (pkt.lat != null && pkt.lon != null ? `` : "") + - `QRZ` + - `
` + - `
` + - `Details` + - `
` + - `Source${escapeHfAprsHtml(pkt.srcCall || "--")}` + - `Destination${escapeHfAprsHtml(pkt.destCall || "--")}` + - `Type${escapeHfAprsHtml(pkt.type || "--")}` + - `Path${escapeHfAprsHtml(pkt.path || "--")}` + - `Age${escapeHfAprsHtml(age)}` + - `CRC${pkt.crcOk ? "OK" : "Failed"}` + - `Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}` + - `Info${escapeHfAprsHtml(pkt.info || "--")}` + - `Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}` + - `
` + - `
`; - - 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 (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { - hfAprsWindow.navigateToAprsMap(lat, lon); - } - }); + return renderAprsPacketRow(pkt, { + fresh: isFresh, + badge: "HF", + distance: hfAprsDistanceText(pkt), + onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); }, + onCopy: (text: string) => { void copyHfAprsCoords(text); }, }); +} - 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") as Clipboard | undefined; - if (clipboard) { - await clipboard.writeText(raw); - hostCore.showHint("Coordinates copied", 1200); - } - } catch { - hostCore.showHint("Copy failed", 1500); - } - })(); }); +async function copyHfAprsCoords(text: string): Promise { + try { + const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; + if (!clipboard) return; + await clipboard.writeText(text); + hostCore.showHint("Coordinates copied", 1200); + } catch { + hostCore.showHint("Copy failed", 1500); } - - return row; } function renderHfAprsHistory() { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs index 1dd8ed14..ee21064b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs @@ -164,3 +164,71 @@ try { await replay.browser.close(); await historyFixture.close(); } + +// The APRS list: one line per frame, with the information field read for the +// operator rather than shown as it arrives on the air. +const APRS_FRAMES = [ + { packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" }, + { packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" }, + { packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" }, + { packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 }, +].map((frame, index) => ({ + src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true, + symbol_table: "/", symbol_code: ">", rig_id: "rig-a", ts_ms: Date.now() - index * 1000, + ...frame, +})); + +const aprsFixture = await startWebFixture({ spectrum: true, history: { aprs: APRS_FRAMES } }); +const aprs = await startBrowser(chromium); + +try { + await aprs.page.setViewportSize({ width: 1400, height: 900 }); + await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); + await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" }); + await aprs.page.waitForTimeout(2000); + await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click(); + await aprs.page.waitForTimeout(400); + + const rows = await aprs.page.evaluate(() => + [...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({ + tag: row.tagName, + height: Math.round(row.getBoundingClientRect().height), + type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "", + summary: row.querySelector(".aprs-line-summary")?.textContent?.trim() ?? "", + }))); + // Newest first, so find each by the type it carries rather than by position. + const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? ""; + assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`); + for (const row of rows) { + assert.equal(row.tag, "DETAILS", "a frame is not expandable in place"); + assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`); + } + // Each payload read rather than echoed: 25 °C from t077, the addressee from + // a message, the sequence from telemetry, the fix from a position report. + assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`); + assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`); + assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`); + assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`); + assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`); + + // The frame as it arrived is still there, one click away. + await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" }) + .locator(".aprs-line").first().click(); + await aprs.page.waitForTimeout(200); + const expanded = await aprs.page.evaluate(() => { + const row = document.querySelector("#aprs-packets .aprs-packet[open]"); + return { + open: row.hasAttribute("open"), + raw: row.querySelector(".aprs-expanded-raw")?.textContent?.trim() ?? "", + meta: row.querySelector(".aprs-expanded-meta")?.textContent ?? "", + }; + }); + assert.equal(expanded.open, true, "the frame did not open"); + assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`); + assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`); + + assert.deepEqual(aprs.runtimeErrors, []); +} finally { + await aprs.browser.close(); + await aprsFixture.close(); +}