diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index 583fcf06..bf10f860 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -6,8 +6,58 @@ const number = typeof value === "number" ? value : Number(value); return Number.isFinite(number) ? number : null; } + function isAlphaNumeric(value) { + return value !== void 0 && /[A-Za-z0-9]/.test(value); + } + function isGrid(value) { + const normalized = value.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR"; + } + function escapeFtxHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); + } + function extractFtxGrids(message) { + return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))]; + } + function tokenize(message) { + return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); + } + function isCallsign(token) { + return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); + } + function extractFtxLocatorDetails(message) { + const tokens = tokenize(message); + const grids = extractFtxGrids(message); + const gridIndex = tokens.findIndex(isGrid); + const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign); + const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? ""); + const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null; + const target = directed ? callsigns[0] ?? null : null; + return grids.map((grid) => ({ grid, station: source, source, target })); + } + function extractFtxCallsign(message) { + return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null; + } + function renderFtxMessage(message) { + let html = ""; + let index = 0; + while (index < message.length) { + if (!isAlphaNumeric(message[index])) { + html += escapeFtxHtml(message[index] ?? ""); + index += 1; + continue; + } + let end = index + 1; + while (end < message.length && isAlphaNumeric(message[end])) end += 1; + const token = message.slice(index, end); + const grid = token.toUpperCase(); + html += isGrid(grid) ? `${grid}` : escapeFtxHtml(token); + index = end; + } + return html; + } function initializeFtxDecoder(config) { - const { id, label, periodMs } = config; + const { id, label, periodMs, periodDigits = 1 } = config; const status = document.getElementById(`${id}-status`); const period = document.getElementById(`${id}-period`); const messagesElement = document.getElementById(`${id}-messages`); @@ -15,9 +65,7 @@ let filterText = ""; let history = []; const renderMessage = (message) => { - if (bridge.renderFt8Message) return bridge.renderFt8Message(message); - if (bridge.ft8EscapeHtml) return bridge.ft8EscapeHtml(message); - return message; + return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message); }; const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5; const prune = () => { @@ -69,9 +117,9 @@ }; const normalize = (message) => { const raw = message.message ?? ""; - const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? []; - const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? []; - const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? null; + const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); + const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const frequency = displayFrequency(message.freq_hz); if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, { @@ -103,6 +151,7 @@ history = []; bridge.updateFt8Bar?.(); render(); + bridge.clearMapMarkersByType?.(id); }; const barFrames = () => { const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); @@ -133,7 +182,7 @@ }; bridge.registerFt8FamilyBarRenderer?.(id, barFrames); const updatePeriod = () => { - if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(1)}s`; + if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`; }; updatePeriod(); window.setInterval(updatePeriod, 250); @@ -141,6 +190,14 @@ filterText = filterInput.value.trim().toUpperCase(); render(); }); + messagesElement?.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid; + if (grid) { + bridge.navigateToMapLocator?.(grid.toUpperCase(), id); + event.preventDefault(); + } + }); const toggle = document.getElementById(`${id}-decode-toggle-btn`); toggle?.addEventListener("click", () => { void (async () => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index 4c03f942..dca9347f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -6,8 +6,58 @@ const number = typeof value === "number" ? value : Number(value); return Number.isFinite(number) ? number : null; } + function isAlphaNumeric(value) { + return value !== void 0 && /[A-Za-z0-9]/.test(value); + } + function isGrid(value) { + const normalized = value.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR"; + } + function escapeFtxHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); + } + function extractFtxGrids(message) { + return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))]; + } + function tokenize(message) { + return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); + } + function isCallsign(token) { + return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); + } + function extractFtxLocatorDetails(message) { + const tokens = tokenize(message); + const grids = extractFtxGrids(message); + const gridIndex = tokens.findIndex(isGrid); + const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign); + const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? ""); + const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null; + const target = directed ? callsigns[0] ?? null : null; + return grids.map((grid) => ({ grid, station: source, source, target })); + } + function extractFtxCallsign(message) { + return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null; + } + function renderFtxMessage(message) { + let html = ""; + let index = 0; + while (index < message.length) { + if (!isAlphaNumeric(message[index])) { + html += escapeFtxHtml(message[index] ?? ""); + index += 1; + continue; + } + let end = index + 1; + while (end < message.length && isAlphaNumeric(message[end])) end += 1; + const token = message.slice(index, end); + const grid = token.toUpperCase(); + html += isGrid(grid) ? `${grid}` : escapeFtxHtml(token); + index = end; + } + return html; + } function initializeFtxDecoder(config) { - const { id, label, periodMs } = config; + const { id, label, periodMs, periodDigits = 1 } = config; const status = document.getElementById(`${id}-status`); const period = document.getElementById(`${id}-period`); const messagesElement = document.getElementById(`${id}-messages`); @@ -15,9 +65,7 @@ let filterText = ""; let history = []; const renderMessage = (message) => { - if (bridge.renderFt8Message) return bridge.renderFt8Message(message); - if (bridge.ft8EscapeHtml) return bridge.ft8EscapeHtml(message); - return message; + return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message); }; const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5; const prune = () => { @@ -69,9 +117,9 @@ }; const normalize = (message) => { const raw = message.message ?? ""; - const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? []; - const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? []; - const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? null; + const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); + const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const frequency = displayFrequency(message.freq_hz); if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, { @@ -103,6 +151,7 @@ history = []; bridge.updateFt8Bar?.(); render(); + bridge.clearMapMarkersByType?.(id); }; const barFrames = () => { const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); @@ -133,7 +182,7 @@ }; bridge.registerFt8FamilyBarRenderer?.(id, barFrames); const updatePeriod = () => { - if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(1)}s`; + if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`; }; updatePeriod(); window.setInterval(updatePeriod, 250); @@ -141,6 +190,14 @@ filterText = filterInput.value.trim().toUpperCase(); render(); }); + messagesElement?.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid; + if (grid) { + bridge.navigateToMapLocator?.(grid.toUpperCase(), id); + event.preventDefault(); + } + }); const toggle = document.getElementById(`${id}-decode-toggle-btn`); toggle?.addEventListener("click", () => { void (async () => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index b00bae6e..8aa5c587 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -1,425 +1,271 @@ "use strict"; -const ft8Status = document.getElementById("ft8-status"); -const ft8PeriodEl = document.getElementById("ft8-period"); -const ft8MessagesEl = document.getElementById("ft8-messages"); -const ft8FilterInput = document.getElementById("ft8-filter"); -const ft8BarOverlay = document.getElementById("ft8-bar-overlay"); -const FT8_BAR_WINDOW_MS = 15 * 60 * 1e3; -const FT8_PERIOD_SECONDS = 15; -const FT8_MAX_DOM_ROWS = 200; -const FT8_BAR_DECODER_LABELS = { - ft8: "FT8", - ft4: "FT4", - ft2: "FT2" -}; -let ft8FilterText = ""; -let ft8MessageHistory = []; -let ft8BarActiveDecoder = "ft8"; -const ft8BarBuilders = {}; -const ft8BarDismissedAtMsByDecoder = { - ft8: 0, - ft4: 0, - ft2: 0 -}; -function currentFt8HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; -} -function pruneFt8MessageHistory() { - const cutoffMs = Date.now() - currentFt8HistoryRetentionMs(); - ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} -function scheduleFt8Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; +(() => { + // src/plugins/ftx-family.ts + var bridge = window; + function finiteNumber(value) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; } - job(); -} -function scheduleFt8HistoryRender() { - scheduleFt8Ui("ft8-history", () => renderFt8History()); -} -function scheduleFt8BarUpdate() { - scheduleFt8Ui("ft8-bar", () => updateFt8Bar()); -} -window.registerFt8FamilyBarRenderer = function(decoder, builder) { - if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return; - ft8BarBuilders[decoder] = builder; -}; -window.setFt8FamilyBarDecoder = function(decoder) { - if (!FT8_BAR_DECODER_LABELS[decoder]) return; - ft8BarActiveDecoder = decoder; - scheduleFt8BarUpdate(); -}; -function normalizeFt8DisplayFreqHz(freqHz) { - const rawHz = Number(freqHz); - if (!Number.isFinite(rawHz)) return null; - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) { - return baseHz + rawHz; + function isAlphaNumeric(value) { + return value !== void 0 && /[A-Za-z0-9]/.test(value); } - return rawHz; -} -function fmtTime(tsMs) { - if (!tsMs) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} -function updateFt8PeriodTimer() { - if (!ft8PeriodEl) return; - const nowSec = Math.floor(Date.now() / 1e3); - const remaining = FT8_PERIOD_SECONDS - nowSec % FT8_PERIOD_SECONDS; - ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`; -} -updateFt8PeriodTimer(); -setInterval(updateFt8PeriodTimer, 500); -function renderFt8Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft8"; - row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = renderFt8Message(rawMessage); - row.innerHTML = `${fmtTime(msg.ts_ms)}${snr}${dt}${freq}${renderedMessage}`; - applyFt8FilterToRow(row); - return row; -} -function renderFt8History() { - pruneFt8MessageHistory(); - if (!ft8MessagesEl) return; - const fragment = document.createDocumentFragment(); - const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS); - for (let i = 0; i < limit; i += 1) { - fragment.appendChild(renderFt8Row(ft8MessageHistory[i])); + function isGrid(value) { + const normalized = value.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR"; } - ft8MessagesEl.replaceChildren(fragment); -} -function addFt8Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft8MessageHistory.unshift(msg); - pruneFt8MessageHistory(); - ft8BarActiveDecoder = "ft8"; - scheduleFt8BarUpdate(); - scheduleFt8HistoryRender(); -} -function normalizeServerFt8Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = ft8ExtractLocatorDetails(raw); - const grids = locatorDetails.length > 0 ? locatorDetails.map((detail) => detail.grid) : ft8ExtractAllGrids(raw); - const station = ft8ExtractLikelyCallsign(raw); - const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - return { - raw, - grids, - station, - rfHz, - locatorDetails, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, - snr_db: msg.snr_db, - dt_s: msg.dt_s, - freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, - message: msg.message + function escapeFtxHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); + } + function extractFtxGrids(message) { + return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))]; + } + function tokenize(message) { + return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); + } + function isCallsign(token) { + return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); + } + function extractFtxLocatorDetails(message) { + const tokens = tokenize(message); + const grids = extractFtxGrids(message); + const gridIndex = tokens.findIndex(isGrid); + const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign); + const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? ""); + const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null; + const target = directed ? callsigns[0] ?? null : null; + return grids.map((grid) => ({ grid, station: source, source, target })); + } + function extractFtxCallsign(message) { + return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null; + } + function renderFtxMessage(message) { + let html = ""; + let index = 0; + while (index < message.length) { + if (!isAlphaNumeric(message[index])) { + html += escapeFtxHtml(message[index] ?? ""); + index += 1; + continue; + } + let end = index + 1; + while (end < message.length && isAlphaNumeric(message[end])) end += 1; + const token = message.slice(index, end); + const grid = token.toUpperCase(); + html += isGrid(grid) ? `${grid}` : escapeFtxHtml(token); + index = end; } - }; -} -window.onServerFt8Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - ft8Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt8Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { - ...msg, - freq_hz: next.rfHz, - locator_details: next.locatorDetails + return html; + } + function installFtxCompatibilityHelpers() { + bridge.renderFt8Message = renderFtxMessage; + bridge.ft8EscapeHtml = escapeFtxHtml; + bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails; + bridge.ft8ExtractAllGrids = extractFtxGrids; + bridge.ft8ExtractLikelyCallsign = extractFtxCallsign; + } + function initializeFt8FamilyBar() { + const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" }; + const builders = {}; + const dismissed = { ft8: 0, ft4: 0, ft2: 0 }; + const overlay = document.getElementById("ft8-bar-overlay"); + let active = "ft8"; + const update = () => { + if (!overlay) return; + const mode = (document.getElementById("mode")?.value ?? "").toUpperCase(); + const result = builders[active]?.(); + if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) { + overlay.style.display = "none"; + overlay.innerHTML = ""; + return; + } + const label = labels[active]; + overlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; + overlay.style.display = "flex"; + }; + bridge.registerFt8FamilyBarRenderer = (decoder, builder) => { + builders[decoder] = builder; + }; + bridge.setFt8FamilyBarDecoder = (decoder) => { + active = decoder; + update(); + }; + bridge.updateFt8Bar = update; + bridge.clearFt8Bar = () => { + ({ ft8: bridge.resetFt8HistoryView, ft4: bridge.resetFt4HistoryView, ft2: bridge.resetFt2HistoryView })[active]?.(); + }; + bridge.closeFt8Bar = () => { + dismissed[active] = Date.now(); + update(); + }; + } + function initializeFtxDecoder(config) { + const { id, label, periodMs, periodDigits = 1 } = config; + const status = document.getElementById(`${id}-status`); + const period = document.getElementById(`${id}-period`); + const messagesElement = document.getElementById(`${id}-messages`); + const filterInput = document.getElementById(`${id}-filter`); + let filterText = ""; + let history = []; + const renderMessage = (message) => { + return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message); + }; + const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5; + const prune = () => { + const cutoff = Date.now() - retentionMs(); + history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff); + }; + const schedule = (job) => { + if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job); + else job(); + }; + const displayFrequency = (value) => { + const raw = finiteNumber(value); + if (raw === null) return null; + const base = finiteNumber(bridge.ft8BaseHz); + return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw; + }; + const renderRow = (message) => { + const row = document.createElement("div"); + row.className = "ft8-row"; + const raw = message.message ?? ""; + row.dataset.message = raw.toUpperCase(); + row.dataset.decoder = id; + const storedFrequency = finiteNumber(message.freq_hz); + row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency); + const snr = finiteNumber(message.snr_db); + const delta = finiteNumber(message.dt_s); + const frequency = displayFrequency(message.freq_hz); + const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); + const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" }); - } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - ft8MessageHistory = normalized.concat(ft8MessageHistory); - pruneFt8MessageHistory(); - ft8BarActiveDecoder = "ft8"; - scheduleFt8BarUpdate(); - scheduleFt8HistoryRender(); -}; -window.restoreFt8History = function(messages) { - window.onServerFt8Batch(messages); -}; -window.pruneFt8HistoryView = function() { - pruneFt8MessageHistory(); - updateFt8Bar(); - renderFt8History(); -}; -function ft8BarRfText(msg) { - const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - if (!Number.isFinite(displayFreqHz)) return null; - return `${displayFreqHz.toFixed(0)} Hz`; -} -function buildFt8BarFrames() { - const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS; - const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const ts = msg.ts_ms ? `${fmtTime(msg.ts_ms)}` : ""; - const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; - const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; - const rf = ft8BarRfText(msg); - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8EscapeHtml((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -function updateFt8Bar() { - if (!ft8BarOverlay) return; - const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); - const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB"; - const decoder = ft8BarActiveDecoder; - const builder = ft8BarBuilders[decoder]; - const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8"; - const result = typeof builder === "function" ? builder() : null; - const newestTsMs = Number(result?.newestTsMs) || 0; - if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) { - ft8BarOverlay.style.display = "none"; - ft8BarOverlay.innerHTML = ""; - return; - } - ft8BarOverlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; - ft8BarOverlay.style.display = "flex"; -} -window.updateFt8Bar = updateFt8Bar; -window.clearFt8Bar = function() { - const decoder = ft8BarActiveDecoder; - if (decoder === "ft4") { - window.resetFt4HistoryView?.(); - return; - } - if (decoder === "ft2") { - window.resetFt2HistoryView?.(); - return; - } - window.resetFt8HistoryView?.(); -}; -window.closeFt8Bar = function() { - ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now(); - if (ft8BarOverlay) { - ft8BarOverlay.style.display = "none"; - ft8BarOverlay.innerHTML = ""; - } -}; -window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames); -function renderFt8Message(message) { - let out = ""; - let i = 0; - while (i < message.length) { - const ch = message[i]; - if (ft8IsAlphaNum(ch)) { - let j = i + 1; - while (j < message.length && ft8IsAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (ft8IsMaidenheadGridToken(grid)) { - out += `${grid}`; - } else { - out += ft8EscapeHtml(token); + row.innerHTML = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; + return row; + }; + const render = () => { + prune(); + if (!messagesElement) return; + const fragment = document.createDocumentFragment(); + let count = 0; + for (const message of history) { + if (count >= 200) break; + if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; + fragment.appendChild(renderRow(message)); + count += 1; } - i = j; - } else { - out += ft8EscapeHtml(ch); - i += 1; - } - } - return out; -} -function ft8TokenizeMessage(message) { - return String(message || "").toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); -} -function ft8ExtractAllGrids(message) { - const out = []; - const seen = /* @__PURE__ */ new Set(); - let i = 0; - while (i < message.length) { - if (ft8IsAlphaNum(message[i])) { - let j = i + 1; - while (j < message.length && ft8IsAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) { - seen.add(grid); - out.push(grid); + messagesElement.replaceChildren(fragment); + }; + const normalize = (message) => { + const raw = message.message ?? ""; + const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); + const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); + const frequency = displayFrequency(message.freq_hz); + if (grids.length > 0) { + bridge.mapAddLocator?.(raw, grids, id, station, { + ...message, + freq_hz: frequency ?? message.freq_hz, + locator_details: locatorDetails + }); } - i = j; - } else { - i += 1; - } - } - return out; -} -function ft8ExtractLocatorDetails(message) { - const tokens = ft8TokenizeMessage(message); - const grids = ft8ExtractAllGrids(String(message || "")); - if (tokens.length === 0 || grids.length === 0) return []; - const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token)); - const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length; - const callsigns = []; - for (let i = 0; i < limit; i += 1) { - if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]); - } - let source = null; - let target = null; - const head = tokens[0]; - if (callsigns.length > 0) { - if (head === "CQ" || head === "DE" || head === "QRZ") { - source = callsigns[0]; - } else if (callsigns.length >= 2) { - target = callsigns[0]; - source = callsigns[1]; - } else { - source = callsigns[0]; - } - } - return grids.map((grid) => ({ - grid, - station: source || null, - source: source || null, - target: target || null - })); -} -function ft8ExtractLikelyCallsign(message) { - const locatorDetails = ft8ExtractLocatorDetails(message); - if (locatorDetails.length > 0 && locatorDetails[0].station) { - return locatorDetails[0].station; - } - const tokens = ft8TokenizeMessage(message); - for (const token of tokens) { - if (ft8IsLikelyCallsignToken(token)) return token; - } - return null; -} -function ft8IsLikelyCallsignToken(token) { - if (!token) return false; - if (token.length < 3 || token.length > 12) return false; - if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false; - if (ft8IsMaidenheadGridToken(token)) return false; - return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); -} -function ft8IsFarewellToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return normalized === "RR73" || normalized === "73" || normalized === "RR"; -} -function ft8IsMaidenheadGridToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized); -} -function ft8EscapeHtml(input) { - return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); -} -function ft8IsAlphaNum(ch) { - return /[A-Za-z0-9]/.test(ch); -} -function activateFt8HistoryLocator(targetEl) { - const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); - if (!locatorEl) return false; - const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); - if (!grid) return false; - if (typeof window.navigateToMapLocator === "function") { - window.navigateToMapLocator(grid, "ft8"); - } - return true; -} -function applyFt8FilterToRow(row) { - if (!ft8FilterText) { - row.style.display = ""; - return; - } - const message = row.dataset.message || ""; - row.style.display = message.includes(ft8FilterText) ? "" : "none"; -} -function applyFt8FilterToAll() { - const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => applyFt8FilterToRow(row)); -} -function updateFt8RowRf(row) { - const freqEl = row.querySelector(".ft8-freq"); - if (!freqEl) return; - const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN; - const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz); - if (Number.isFinite(displayFreqHz)) { - freqEl.textContent = displayFreqHz.toFixed(0); - } else { - freqEl.textContent = "--"; - } -} -window.updateFt8RfDisplay = function() { - const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => updateFt8RowRf(row)); - updateFt8Bar(); -}; -window.resetFt8HistoryView = function() { - ft8MessagesEl.innerHTML = ""; - ft8MessageHistory = []; - updateFt8Bar(); - renderFt8History(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8"); -}; -if (ft8FilterInput) { - ft8FilterInput.addEventListener("input", () => { - ft8FilterText = ft8FilterInput.value.trim().toUpperCase(); - renderFt8History(); - }); -} -if (ft8MessagesEl) { - ft8MessagesEl.addEventListener("click", (event) => { - if (!activateFt8HistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); - ft8MessagesEl.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (!activateFt8HistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); -} -const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn"); -ft8DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn); - await postPath("/toggle_ft8_decode"); - } catch (e) { - console.error("FT8 toggle failed", e); - } -}); -document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft8_decode"); - window.resetFt8HistoryView(); - } catch (e) { - console.error("FT8 history clear failed", e); - } -}); -window.onServerFt8 = function(msg) { - ft8Status.textContent = "Receiving"; - const next = normalizeServerFt8Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { - ...msg, - freq_hz: next.rfHz, - locator_details: next.locatorDetails + return { + receiver: bridge.getDecodeRigMeta?.() ?? null, + ts_ms: message.ts_ms, + snr_db: message.snr_db, + dt_s: message.dt_s, + freq_hz: frequency ?? message.freq_hz, + message: message.message, + _tsMs: finiteNumber(message.ts_ms) ?? Date.now() + }; + }; + const receiveBatch = (messages) => { + if (messages.length === 0) return; + if (status) status.textContent = "Receiving"; + history = messages.map(normalize).reverse().concat(history); + prune(); + bridge.setFt8FamilyBarDecoder?.(id); + bridge.updateFt8Bar?.(); + schedule(render); + }; + const reset = () => { + history = []; + bridge.updateFt8Bar?.(); + render(); + bridge.clearMapMarkersByType?.(id); + }; + const barFrames = () => { + const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); + let html = ""; + for (const message of recent) { + const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); + const time = timestamp === null ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; + const snr = finiteNumber(message.snr_db); + const delta = finiteNumber(message.dt_s); + const frequency = displayFrequency(message.freq_hz); + const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · "); + html += `
${time}${renderMessage(message.message ?? "")}${detail ? ` · ${detail}` : ""}
`; + } + return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html }; + }; + const capitalized = `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`; + bridge[`onServer${capitalized}Batch`] = (messages) => { + if (Array.isArray(messages)) receiveBatch(messages); + }; + bridge[`restore${capitalized}History`] = receiveBatch; + bridge[`prune${capitalized}HistoryView`] = () => { + prune(); + render(); + }; + bridge[`reset${capitalized}HistoryView`] = reset; + bridge[`onServer${capitalized}`] = (message) => { + receiveBatch([message]); + }; + bridge.registerFt8FamilyBarRenderer?.(id, barFrames); + const updatePeriod = () => { + if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`; + }; + updatePeriod(); + window.setInterval(updatePeriod, 250); + filterInput?.addEventListener("input", () => { + filterText = filterInput.value.trim().toUpperCase(); + render(); + }); + messagesElement?.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid; + if (grid) { + bridge.navigateToMapLocator?.(grid.toUpperCase(), id); + event.preventDefault(); + } + }); + const toggle = document.getElementById(`${id}-decode-toggle-btn`); + toggle?.addEventListener("click", () => { + void (async () => { + try { + await bridge.takeSchedulerControlForDecoderDisable?.(toggle); + await bridge.postPath?.(`/toggle_${id}_decode`); + } catch (error) { + console.error(`${label} toggle failed`, error); + } + })(); + }); + document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { + void (async () => { + if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return; + try { + await bridge.postPath?.(`/clear_${id}_decode`); + reset(); + } catch (error) { + console.error(`${label} history clear failed`, error); + } + })(); }); } - addFt8Message(next.history); -}; + + // src/plugins/ft8.ts + installFtxCompatibilityHelpers(); + initializeFt8FamilyBar(); + initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 }); +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index d929c723..c73bd27f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -26,7 +26,6 @@ await build({ aprs: path.join(sourceDir, "plugins", "aprs.js"), "background-decode": path.join(sourceDir, "plugins", "background-decode.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), - ft8: path.join(sourceDir, "plugins", "ft8.js"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), sat: path.join(sourceDir, "plugins", "sat.js"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"), @@ -50,6 +49,7 @@ await build({ ft4: path.join(sourceDir, "plugins", "ft4.ts"), wspr: path.join(sourceDir, "plugins", "wspr.ts"), cw: path.join(sourceDir, "plugins", "cw.ts"), + ft8: path.join(sourceDir, "plugins", "ft8.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js deleted file mode 100644 index c9c4edff..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js +++ /dev/null @@ -1,486 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// --- FT8 Decoder Plugin (server-side decode) --- -const ft8Status = document.getElementById("ft8-status"); -const ft8PeriodEl = document.getElementById("ft8-period"); -const ft8MessagesEl = document.getElementById("ft8-messages"); -const ft8FilterInput = document.getElementById("ft8-filter"); -const ft8BarOverlay = document.getElementById("ft8-bar-overlay"); -const FT8_BAR_WINDOW_MS = 15 * 60 * 1000; -const FT8_PERIOD_SECONDS = 15; -const FT8_MAX_DOM_ROWS = 200; -const FT8_BAR_DECODER_LABELS = { - ft8: "FT8", - ft4: "FT4", - ft2: "FT2", -}; -let ft8FilterText = ""; -let ft8MessageHistory = []; -let ft8BarActiveDecoder = "ft8"; -const ft8BarBuilders = {}; -const ft8BarDismissedAtMsByDecoder = { - ft8: 0, - ft4: 0, - ft2: 0, -}; - -function currentFt8HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneFt8MessageHistory() { - const cutoffMs = Date.now() - currentFt8HistoryRetentionMs(); - ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleFt8Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; - } - job(); -} - -function scheduleFt8HistoryRender() { - scheduleFt8Ui("ft8-history", () => renderFt8History()); -} - -function scheduleFt8BarUpdate() { - scheduleFt8Ui("ft8-bar", () => updateFt8Bar()); -} - -window.registerFt8FamilyBarRenderer = function(decoder, builder) { - if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return; - ft8BarBuilders[decoder] = builder; -}; - -window.setFt8FamilyBarDecoder = function(decoder) { - if (!FT8_BAR_DECODER_LABELS[decoder]) return; - ft8BarActiveDecoder = decoder; - scheduleFt8BarUpdate(); -}; - -function normalizeFt8DisplayFreqHz(freqHz) { - const rawHz = Number(freqHz); - if (!Number.isFinite(rawHz)) return null; - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) { - return baseHz + rawHz; - } - return rawHz; -} - -function fmtTime(tsMs) { - if (!tsMs) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -function updateFt8PeriodTimer() { - if (!ft8PeriodEl) return; - const nowSec = Math.floor(Date.now() / 1000); - const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS); - ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`; -} - -updateFt8PeriodTimer(); -setInterval(updateFt8PeriodTimer, 500); - -function renderFt8Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft8"; - row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = renderFt8Message(rawMessage); - row.innerHTML = `${fmtTime(msg.ts_ms)}${snr}${dt}${freq}${renderedMessage}`; - applyFt8FilterToRow(row); - return row; -} - -function renderFt8History() { - pruneFt8MessageHistory(); - if (!ft8MessagesEl) return; - const fragment = document.createDocumentFragment(); - const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS); - for (let i = 0; i < limit; i += 1) { - fragment.appendChild(renderFt8Row(ft8MessageHistory[i])); - } - ft8MessagesEl.replaceChildren(fragment); -} - -function addFt8Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft8MessageHistory.unshift(msg); - pruneFt8MessageHistory(); - ft8BarActiveDecoder = "ft8"; - scheduleFt8BarUpdate(); - scheduleFt8HistoryRender(); -} - -function normalizeServerFt8Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = ft8ExtractLocatorDetails(raw); - const grids = locatorDetails.length > 0 - ? locatorDetails.map((detail) => detail.grid) - : ft8ExtractAllGrids(raw); - const station = ft8ExtractLikelyCallsign(raw); - const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - return { - raw, - grids, - station, - rfHz, - locatorDetails, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, - snr_db: msg.snr_db, - dt_s: msg.dt_s, - freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, - message: msg.message, - }, - }; -} - -window.onServerFt8Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - ft8Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt8Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { - ...msg, - freq_hz: next.rfHz, - locator_details: next.locatorDetails, - }); - } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - ft8MessageHistory = normalized.concat(ft8MessageHistory); - pruneFt8MessageHistory(); - ft8BarActiveDecoder = "ft8"; - scheduleFt8BarUpdate(); - scheduleFt8HistoryRender(); -}; - -window.restoreFt8History = function(messages) { - window.onServerFt8Batch(messages); -}; - -window.pruneFt8HistoryView = function() { - pruneFt8MessageHistory(); - updateFt8Bar(); - renderFt8History(); -}; - -function ft8BarRfText(msg) { - const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - if (!Number.isFinite(displayFreqHz)) return null; - return `${displayFreqHz.toFixed(0)} Hz`; -} - -function buildFt8BarFrames() { - const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS; - const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const ts = msg.ts_ms ? `${fmtTime(msg.ts_ms)}` : ""; - const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; - const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; - const rf = ft8BarRfText(msg); - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8EscapeHtml((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} - -function updateFt8Bar() { - if (!ft8BarOverlay) return; - const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); - const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB"; - const decoder = ft8BarActiveDecoder; - const builder = ft8BarBuilders[decoder]; - const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8"; - const result = typeof builder === "function" ? builder() : null; - const newestTsMs = Number(result?.newestTsMs) || 0; - if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) { - ft8BarOverlay.style.display = "none"; - ft8BarOverlay.innerHTML = ""; - return; - } - - ft8BarOverlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; - ft8BarOverlay.style.display = "flex"; -} -window.updateFt8Bar = updateFt8Bar; -window.clearFt8Bar = function() { - const decoder = ft8BarActiveDecoder; - if (decoder === "ft4") { - window.resetFt4HistoryView?.(); - return; - } - if (decoder === "ft2") { - window.resetFt2HistoryView?.(); - return; - } - window.resetFt8HistoryView?.(); -}; -window.closeFt8Bar = function() { - ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now(); - if (ft8BarOverlay) { - ft8BarOverlay.style.display = "none"; - ft8BarOverlay.innerHTML = ""; - } -}; -window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames); - -function renderFt8Message(message) { - let out = ""; - let i = 0; - while (i < message.length) { - const ch = message[i]; - if (ft8IsAlphaNum(ch)) { - let j = i + 1; - while (j < message.length && ft8IsAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (ft8IsMaidenheadGridToken(grid)) { - out += `${grid}`; - } else { - out += ft8EscapeHtml(token); - } - i = j; - } else { - out += ft8EscapeHtml(ch); - i += 1; - } - } - return out; -} - -function ft8TokenizeMessage(message) { - return String(message || "") - .toUpperCase() - .split(/[^A-Z0-9/]+/) - .filter(Boolean); -} - -function ft8ExtractAllGrids(message) { - const out = []; - const seen = new Set(); - let i = 0; - while (i < message.length) { - if (ft8IsAlphaNum(message[i])) { - let j = i + 1; - while (j < message.length && ft8IsAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) { - seen.add(grid); - out.push(grid); - } - i = j; - } else { - i += 1; - } - } - return out; -} - -function ft8ExtractLocatorDetails(message) { - const tokens = ft8TokenizeMessage(message); - const grids = ft8ExtractAllGrids(String(message || "")); - if (tokens.length === 0 || grids.length === 0) return []; - const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token)); - const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length; - const callsigns = []; - for (let i = 0; i < limit; i += 1) { - if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]); - } - - let source = null; - let target = null; - const head = tokens[0]; - if (callsigns.length > 0) { - if (head === "CQ" || head === "DE" || head === "QRZ") { - source = callsigns[0]; - } else if (callsigns.length >= 2) { - target = callsigns[0]; - source = callsigns[1]; - } else { - source = callsigns[0]; - } - } - - return grids.map((grid) => ({ - grid, - station: source || null, - source: source || null, - target: target || null, - })); -} - -function ft8ExtractLikelyCallsign(message) { - const locatorDetails = ft8ExtractLocatorDetails(message); - if (locatorDetails.length > 0 && locatorDetails[0].station) { - return locatorDetails[0].station; - } - const tokens = ft8TokenizeMessage(message); - for (const token of tokens) { - if (ft8IsLikelyCallsignToken(token)) return token; - } - return null; -} - -function ft8IsLikelyCallsignToken(token) { - if (!token) return false; - if (token.length < 3 || token.length > 12) return false; - if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false; - if (ft8IsMaidenheadGridToken(token)) return false; - return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); -} - -function ft8IsFarewellToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return normalized === "RR73" || normalized === "73" || normalized === "RR"; -} - -function ft8IsMaidenheadGridToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized); -} - -function ft8EscapeHtml(input) { - return input - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """); -} - -function ft8IsAlphaNum(ch) { - return /[A-Za-z0-9]/.test(ch); -} - -function activateFt8HistoryLocator(targetEl) { - const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); - if (!locatorEl) return false; - const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); - if (!grid) return false; - if (typeof window.navigateToMapLocator === "function") { - window.navigateToMapLocator(grid, "ft8"); - } - return true; -} - -function applyFt8FilterToRow(row) { - if (!ft8FilterText) { - row.style.display = ""; - return; - } - const message = row.dataset.message || ""; - row.style.display = message.includes(ft8FilterText) ? "" : "none"; -} - -function applyFt8FilterToAll() { - const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => applyFt8FilterToRow(row)); -} - -function updateFt8RowRf(row) { - const freqEl = row.querySelector(".ft8-freq"); - if (!freqEl) return; - const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN; - const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz); - if (Number.isFinite(displayFreqHz)) { - freqEl.textContent = displayFreqHz.toFixed(0); - } else { - freqEl.textContent = "--"; - } -} - -window.updateFt8RfDisplay = function() { - const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => updateFt8RowRf(row)); - updateFt8Bar(); -}; - -window.resetFt8HistoryView = function() { - ft8MessagesEl.innerHTML = ""; - ft8MessageHistory = []; - updateFt8Bar(); - renderFt8History(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8"); -}; - -if (ft8FilterInput) { - ft8FilterInput.addEventListener("input", () => { - ft8FilterText = ft8FilterInput.value.trim().toUpperCase(); - renderFt8History(); - }); -} - -if (ft8MessagesEl) { - ft8MessagesEl.addEventListener("click", (event) => { - if (!activateFt8HistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); - ft8MessagesEl.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (!activateFt8HistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); -} - -const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn"); -ft8DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn); - await postPath("/toggle_ft8_decode"); - } catch (e) { - console.error("FT8 toggle failed", e); - } -}); - -document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft8_decode"); - window.resetFt8HistoryView(); - } catch (e) { - console.error("FT8 history clear failed", e); - } -}); - -// --- Server-side FT8 decode handler --- -window.onServerFt8 = function(msg) { - ft8Status.textContent = "Receiving"; - const next = normalizeServerFt8Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { - ...msg, - freq_hz: next.rfHz, - locator_details: next.locatorDetails, - }); - } - addFt8Message(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.ts new file mode 100644 index 00000000..9e2c5949 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import { + initializeFt8FamilyBar, + initializeFtxDecoder, + installFtxCompatibilityHelpers, +} from "./ftx-family"; + +installFtxCompatibilityHelpers(); +initializeFt8FamilyBar(); +initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15_000, periodDigits: 0 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts index 0c27c921..31fac660 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -export type FtxDecoderId = "ft2" | "ft4"; +export type FtxDecoderId = "ft2" | "ft4" | "ft8"; interface FtxMessage { message?: string | undefined; @@ -15,7 +15,7 @@ interface FtxMessage { [key: string]: unknown; } -interface LocatorDetails { grid: string; [key: string]: unknown } +export interface LocatorDetails { grid: string; station: string | null; source: string | null; target: string | null } interface BarFrames { count: number; newestTsMs: number; html: string } interface ConfirmApi { confirm(options: { title: string; message: string; confirmLabel: string }): Promise; @@ -37,6 +37,8 @@ interface FtxBridge { station: string | null, message: FtxMessage, ) => void; + navigateToMapLocator?: (grid: string, decoder: FtxDecoderId) => void; + clearMapMarkersByType?: (decoder: FtxDecoderId) => void; setFt8FamilyBarDecoder?: (decoder: FtxDecoderId) => void; updateFt8Bar?: () => void; registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void; @@ -44,13 +46,19 @@ interface FtxBridge { postPath?: (path: string) => Promise; fmtTime?: (timestampMs: number) => string; trxUi: ConfirmApi; + resetFt2HistoryView?: () => void; + resetFt4HistoryView?: () => void; + resetFt8HistoryView?: () => void; + clearFt8Bar?: () => void; + closeFt8Bar?: () => void; [key: string]: unknown; } interface FtxConfig { id: FtxDecoderId; - label: "FT2" | "FT4"; + label: "FT2" | "FT4" | "FT8"; periodMs: number; + periodDigits?: number; } const bridge = window as unknown as FtxBridge; @@ -60,8 +68,112 @@ function finiteNumber(value: unknown): number | null { return Number.isFinite(number) ? number : null; } +function isAlphaNumeric(value: string | undefined): boolean { + return value !== undefined && /[A-Za-z0-9]/.test(value); +} + +function isGrid(value: string): boolean { + const normalized = value.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) + && normalized !== "RR73" && normalized !== "73" && normalized !== "RR"; +} + +export function escapeFtxHtml(input: string): string { + return input.replaceAll("&", "&").replaceAll("<", "<") + .replaceAll(">", ">").replaceAll('"', """); +} + +export function extractFtxGrids(message: string): string[] { + return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))]; +} + +function tokenize(message: string): string[] { + return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); +} + +function isCallsign(token: string | undefined): token is string { + return token !== undefined && token.length >= 3 && token.length <= 12 + && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) + && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); +} + +export function extractFtxLocatorDetails(message: string): LocatorDetails[] { + const tokens = tokenize(message); + const grids = extractFtxGrids(message); + const gridIndex = tokens.findIndex(isGrid); + const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign); + const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? ""); + const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null; + const target = directed ? callsigns[0] ?? null : null; + return grids.map((grid) => ({ grid, station: source, source, target })); +} + +export function extractFtxCallsign(message: string): string | null { + return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null; +} + +export function renderFtxMessage(message: string): string { + let html = ""; + let index = 0; + while (index < message.length) { + if (!isAlphaNumeric(message[index])) { + html += escapeFtxHtml(message[index] ?? ""); + index += 1; + continue; + } + let end = index + 1; + while (end < message.length && isAlphaNumeric(message[end])) end += 1; + const token = message.slice(index, end); + const grid = token.toUpperCase(); + html += isGrid(grid) + ? `${grid}` + : escapeFtxHtml(token); + index = end; + } + return html; +} + +export function installFtxCompatibilityHelpers(): void { + bridge.renderFt8Message = renderFtxMessage; + bridge.ft8EscapeHtml = escapeFtxHtml; + bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails; + bridge.ft8ExtractAllGrids = extractFtxGrids; + bridge.ft8ExtractLikelyCallsign = extractFtxCallsign; +} + +export function initializeFt8FamilyBar(): void { + const labels: Record = { ft8: "FT8", ft4: "FT4", ft2: "FT2" }; + const builders: Partial BarFrames>> = {}; + const dismissed: Record = { ft8: 0, ft4: 0, ft2: 0 }; + const overlay = document.getElementById("ft8-bar-overlay"); + let active: FtxDecoderId = "ft8"; + const update = (): void => { + if (!overlay) return; + const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value ?? "").toUpperCase(); + const result = builders[active]?.(); + if ((mode !== "DIG" && mode !== "USB") || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) { + overlay.style.display = "none"; + overlay.innerHTML = ""; + return; + } + const label = labels[active]; + overlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; + overlay.style.display = "flex"; + }; + bridge.registerFt8FamilyBarRenderer = (decoder, builder) => { builders[decoder] = builder; }; + bridge.setFt8FamilyBarDecoder = (decoder) => { active = decoder; update(); }; + bridge.updateFt8Bar = update; + bridge.clearFt8Bar = () => { + ({ ft8: bridge.resetFt8HistoryView, ft4: bridge.resetFt4HistoryView, ft2: bridge.resetFt2HistoryView })[active]?.(); + }; + bridge.closeFt8Bar = () => { + dismissed[active] = Date.now(); + update(); + }; +} + export function initializeFtxDecoder(config: FtxConfig): void { - const { id, label, periodMs } = config; + const { id, label, periodMs, periodDigits = 1 } = config; const status = document.getElementById(`${id}-status`); const period = document.getElementById(`${id}-period`); const messagesElement = document.getElementById(`${id}-messages`); @@ -70,9 +182,7 @@ export function initializeFtxDecoder(config: FtxConfig): void { let history: FtxMessage[] = []; const renderMessage = (message: string): string => { - if (bridge.renderFt8Message) return bridge.renderFt8Message(message); - if (bridge.ft8EscapeHtml) return bridge.ft8EscapeHtml(message); - return message; + return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message); }; const retentionMs = (): number => bridge.getDecodeHistoryRetentionMs?.() ?? 86_400_000; const prune = (): void => { @@ -122,11 +232,11 @@ export function initializeFtxDecoder(config: FtxConfig): void { }; const normalize = (message: FtxMessage): FtxMessage => { const raw = message.message ?? ""; - const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? []; + const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) - : bridge.ft8ExtractAllGrids?.(raw) ?? []; - const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? null; + : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const frequency = displayFrequency(message.freq_hz); if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, { @@ -156,6 +266,7 @@ export function initializeFtxDecoder(config: FtxConfig): void { history = []; bridge.updateFt8Bar?.(); render(); + bridge.clearMapMarkersByType?.(id); }; const barFrames = (): BarFrames => { const recent = history @@ -183,11 +294,16 @@ export function initializeFtxDecoder(config: FtxConfig): void { bridge.registerFt8FamilyBarRenderer?.(id, barFrames); const updatePeriod = (): void => { - if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1000).toFixed(1)}s`; + if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1000).toFixed(periodDigits)}s`; }; updatePeriod(); window.setInterval(updatePeriod, 250); filterInput?.addEventListener("input", () => { filterText = filterInput.value.trim().toUpperCase(); render(); }); + messagesElement?.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid; + if (grid) { bridge.navigateToMapLocator?.(grid.toUpperCase(), id); event.preventDefault(); } + }); const toggle = document.getElementById(`${id}-decode-toggle-btn`); toggle?.addEventListener("click", () => { void (async () => { try { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs index 58e81dc6..3734ee9f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs @@ -37,5 +37,34 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async ( assert.equal(mapMessages[0][4].freq_hz, 14_075_250); const bar = barRenderer(); assert.equal(bar.count, 1); - assert.match(bar.html, /CQ SP0ABC JO91/); + assert.match(bar.html, /CQ SP0ABC/); + assert.match(bar.html, /JO91/); +}); + +test("FT8 entry installs shared parsing without relying on script globals", async () => { + const forwarded = []; + const window = { + ft8BaseHz: 7_074_000, + trxUi: { confirm: async () => true }, + mapAddLocator: (...args) => { forwarded.push(args); }, + setInterval() { return 1; }, + }; + const context = vm.createContext({ + window, + document: { getElementById: () => null, createElement: () => ({ dataset: {} }), createDocumentFragment: () => ({ appendChild() {} }) }, + Date, + Number, + String, + Array, + Set, + console, + }); + const source = await readFile(new URL("../../assets/web/generated/ft8.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + const details = window.ft8ExtractLocatorDetails("K1ABC SP0ABC JO91"); + assert.equal(details[0].source, "SP0ABC"); + assert.equal(details[0].target, "K1ABC"); + window.onServerFt8({ message: "K1ABC SP0ABC JO91", freq_hz: 500, ts_ms: Date.now() }); + assert.equal(forwarded[0][4].freq_hz, 7_074_500); });