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 c682b6d6..583fcf06 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 @@ -1,195 +1,170 @@ "use strict"; -function ft8RenderMessageFt2(message) { - if (typeof renderFt8Message === "function") return renderFt8Message(message); - if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); - return message; -} -const ft2Status = document.getElementById("ft2-status"); -const ft2PeriodEl = document.getElementById("ft2-period"); -const ft2MessagesEl = document.getElementById("ft2-messages"); -const ft2FilterInput = document.getElementById("ft2-filter"); -const FT2_PERIOD_MS = 3750; -const FT2_MAX_DOM_ROWS = 200; -let ft2FilterText = ""; -let ft2MessageHistory = []; -function currentFt2HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; -} -function pruneFt2MessageHistory() { - const cutoffMs = Date.now() - currentFt2HistoryRetentionMs(); - ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} -function scheduleFt2Ui(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 scheduleFt2HistoryRender() { - scheduleFt2Ui("ft2-history", () => renderFt2History()); -} -function normalizeFt2DisplayFreqHz(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 initializeFtxDecoder(config) { + const { id, label, periodMs } = 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) => { + if (bridge.renderFt8Message) return bridge.renderFt8Message(message); + if (bridge.ft8EscapeHtml) return bridge.ft8EscapeHtml(message); + return 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" + }); + 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; + } + messagesElement.replaceChildren(fragment); + }; + 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 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 + }); + } + 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(); + }; + 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(1)}s`; + }; + updatePeriod(); + window.setInterval(updatePeriod, 250); + filterInput?.addEventListener("input", () => { + filterText = filterInput.value.trim().toUpperCase(); + render(); + }); + 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); + } + })(); + }); } - return rawHz; -} -function updateFt2PeriodTimer() { - if (!ft2PeriodEl) return; - const nowMs = Date.now(); - const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1e3; - ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; -} -updateFt2PeriodTimer(); -setInterval(updateFt2PeriodTimer, 250); -function renderFt2Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft2"; - 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 = normalizeFt2DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = ft8RenderMessageFt2(rawMessage); - const tsMs = msg._tsMs ?? msg.ts_ms; - const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; - row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; - return row; -} -function renderFt2History() { - pruneFt2MessageHistory(); - if (!ft2MessagesEl) return; - const filter = ft2FilterText; - const fragment = document.createDocumentFragment(); - let rendered = 0; - for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) { - const msg = ft2MessageHistory[i]; - if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; - fragment.appendChild(renderFt2Row(msg)); - rendered++; - } - ft2MessagesEl.replaceChildren(fragment); -} -function addFt2Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft2MessageHistory.unshift(msg); - pruneFt2MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft2"); - window.updateFt8Bar?.(); - scheduleFt2HistoryRender(); -} -function normalizeServerFt2Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; - const grids = locatorDetails.length > 0 ? locatorDetails.map((d) => d.grid) : typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []; - const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; - const rfHz = normalizeFt2DisplayFreqHz(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.onServerFt2Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - if (ft2Status) ft2Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt2Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft2", 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(); - ft2MessageHistory = normalized.concat(ft2MessageHistory); - pruneFt2MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft2"); - window.updateFt8Bar?.(); - scheduleFt2HistoryRender(); -}; -window.restoreFt2History = function(messages) { - window.onServerFt2Batch(messages); -}; -window.pruneFt2HistoryView = function() { - pruneFt2MessageHistory(); - renderFt2History(); -}; -window.resetFt2HistoryView = function() { - if (ft2MessagesEl) ft2MessagesEl.innerHTML = ""; - ft2MessageHistory = []; - window.updateFt8Bar?.(); - renderFt2History(); -}; -function buildFt2BarFrames() { - const cutoffMs = Date.now() - 15 * 60 * 1e3; - const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const tsMs = msg._tsMs ?? msg.ts_ms; - const ts = tsMs ? `${fmtTime(tsMs)}` : ""; - 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 displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz); - const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8RenderMessageFt2((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames); -if (ft2FilterInput) { - ft2FilterInput.addEventListener("input", () => { - ft2FilterText = ft2FilterInput.value.trim().toUpperCase(); - renderFt2History(); - }); -} -const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn"); -ft2DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn); - await postPath("/toggle_ft2_decode"); - } catch (e) { - console.error("FT2 toggle failed", e); - } -}); -document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft2_decode"); - window.resetFt2HistoryView(); - } catch (e) { - console.error("FT2 history clear failed", e); - } -}); -window.onServerFt2 = function(msg) { - if (ft2Status) ft2Status.textContent = "Receiving"; - const next = normalizeServerFt2Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - addFt2Message(next.history); -}; + + // src/plugins/ft2.ts + initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 }); +})(); 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 4387020e..4c03f942 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 @@ -1,195 +1,170 @@ "use strict"; -function ft8RenderMessage(message) { - if (typeof renderFt8Message === "function") return renderFt8Message(message); - if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); - return message; -} -const ft4Status = document.getElementById("ft4-status"); -const ft4PeriodEl = document.getElementById("ft4-period"); -const ft4MessagesEl = document.getElementById("ft4-messages"); -const ft4FilterInput = document.getElementById("ft4-filter"); -const FT4_PERIOD_MS = 7500; -const FT4_MAX_DOM_ROWS = 200; -let ft4FilterText = ""; -let ft4MessageHistory = []; -function currentFt4HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; -} -function pruneFt4MessageHistory() { - const cutoffMs = Date.now() - currentFt4HistoryRetentionMs(); - ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} -function scheduleFt4Ui(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 scheduleFt4HistoryRender() { - scheduleFt4Ui("ft4-history", () => renderFt4History()); -} -function normalizeFt4DisplayFreqHz(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 initializeFtxDecoder(config) { + const { id, label, periodMs } = 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) => { + if (bridge.renderFt8Message) return bridge.renderFt8Message(message); + if (bridge.ft8EscapeHtml) return bridge.ft8EscapeHtml(message); + return 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" + }); + 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; + } + messagesElement.replaceChildren(fragment); + }; + 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 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 + }); + } + 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(); + }; + 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(1)}s`; + }; + updatePeriod(); + window.setInterval(updatePeriod, 250); + filterInput?.addEventListener("input", () => { + filterText = filterInput.value.trim().toUpperCase(); + render(); + }); + 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); + } + })(); + }); } - return rawHz; -} -function updateFt4PeriodTimer() { - if (!ft4PeriodEl) return; - const nowMs = Date.now(); - const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1e3; - ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; -} -updateFt4PeriodTimer(); -setInterval(updateFt4PeriodTimer, 250); -function renderFt4Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft4"; - 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 = normalizeFt4DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = ft8RenderMessage(rawMessage); - const tsMs = msg._tsMs ?? msg.ts_ms; - const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; - row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; - return row; -} -function renderFt4History() { - pruneFt4MessageHistory(); - if (!ft4MessagesEl) return; - const filter = ft4FilterText; - const fragment = document.createDocumentFragment(); - let rendered = 0; - for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) { - const msg = ft4MessageHistory[i]; - if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; - fragment.appendChild(renderFt4Row(msg)); - rendered++; - } - ft4MessagesEl.replaceChildren(fragment); -} -function addFt4Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft4MessageHistory.unshift(msg); - pruneFt4MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft4"); - window.updateFt8Bar?.(); - scheduleFt4HistoryRender(); -} -function normalizeServerFt4Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; - const grids = locatorDetails.length > 0 ? locatorDetails.map((d) => d.grid) : typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []; - const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; - const rfHz = normalizeFt4DisplayFreqHz(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.onServerFt4Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - if (ft4Status) ft4Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt4Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft4", 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(); - ft4MessageHistory = normalized.concat(ft4MessageHistory); - pruneFt4MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft4"); - window.updateFt8Bar?.(); - scheduleFt4HistoryRender(); -}; -window.restoreFt4History = function(messages) { - window.onServerFt4Batch(messages); -}; -window.pruneFt4HistoryView = function() { - pruneFt4MessageHistory(); - renderFt4History(); -}; -window.resetFt4HistoryView = function() { - if (ft4MessagesEl) ft4MessagesEl.innerHTML = ""; - ft4MessageHistory = []; - window.updateFt8Bar?.(); - renderFt4History(); -}; -function buildFt4BarFrames() { - const cutoffMs = Date.now() - 15 * 60 * 1e3; - const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const tsMs = msg._tsMs ?? msg.ts_ms; - const ts = tsMs ? `${fmtTime(tsMs)}` : ""; - 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 displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz); - const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8RenderMessage((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames); -if (ft4FilterInput) { - ft4FilterInput.addEventListener("input", () => { - ft4FilterText = ft4FilterInput.value.trim().toUpperCase(); - renderFt4History(); - }); -} -const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn"); -ft4DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn); - await postPath("/toggle_ft4_decode"); - } catch (e) { - console.error("FT4 toggle failed", e); - } -}); -document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft4_decode"); - window.resetFt4HistoryView(); - } catch (e) { - console.error("FT4 history clear failed", e); - } -}); -window.onServerFt4 = function(msg) { - if (ft4Status) ft4Status.textContent = "Receiving"; - const next = normalizeServerFt4Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - addFt4Message(next.history); -}; + + // src/plugins/ft4.ts + initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 }); +})(); 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 bd20a092..8a1825ef 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 @@ -27,8 +27,6 @@ await build({ "background-decode": path.join(sourceDir, "plugins", "background-decode.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), cw: path.join(sourceDir, "plugins", "cw.js"), - ft2: path.join(sourceDir, "plugins", "ft2.js"), - ft4: path.join(sourceDir, "plugins", "ft4.js"), ft8: path.join(sourceDir, "plugins", "ft8.js"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), sat: path.join(sourceDir, "plugins", "sat.js"), @@ -48,6 +46,22 @@ await build({ logLevel: "info", }); +await build({ + entryPoints: { + ft2: path.join(sourceDir, "plugins", "ft2.ts"), + ft4: path.join(sourceDir, "plugins", "ft4.ts"), + }, + outdir: outputDir, + bundle: true, + format: "iife", + platform: "browser", + target: "es2022", + sourcemap: false, + legalComments: "inline", + charset: "utf8", + logLevel: "info", +}); + await build({ entryPoints: [path.join(sourceDir, "decode-history-worker.ts")], outfile: path.join(outputDir, "decode-history-worker.js"), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js deleted file mode 100644 index cc27230a..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js +++ /dev/null @@ -1,207 +0,0 @@ -// --- FT2 Decoder Plugin (server-side decode) --- -// SPDX-FileCopyrightText: 2026 Stan Grams -// SPDX-License-Identifier: GPL-2.0-or-later - -function ft8RenderMessageFt2(message) { - if (typeof renderFt8Message === "function") return renderFt8Message(message); - if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); - return message; -} - -const ft2Status = document.getElementById("ft2-status"); -const ft2PeriodEl = document.getElementById("ft2-period"); -const ft2MessagesEl = document.getElementById("ft2-messages"); -const ft2FilterInput = document.getElementById("ft2-filter"); -const FT2_PERIOD_MS = 3750; -const FT2_MAX_DOM_ROWS = 200; -let ft2FilterText = ""; -let ft2MessageHistory = []; - -function currentFt2HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneFt2MessageHistory() { - const cutoffMs = Date.now() - currentFt2HistoryRetentionMs(); - ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleFt2Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; - } - job(); -} - -function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); } - -function normalizeFt2DisplayFreqHz(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 updateFt2PeriodTimer() { - if (!ft2PeriodEl) return; - const nowMs = Date.now(); - const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000; - ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; -} - -updateFt2PeriodTimer(); -setInterval(updateFt2PeriodTimer, 250); - -function renderFt2Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft2"; - 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 = normalizeFt2DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = ft8RenderMessageFt2(rawMessage); - const tsMs = msg._tsMs ?? msg.ts_ms; - const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; - row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; - return row; -} - -function renderFt2History() { - pruneFt2MessageHistory(); - if (!ft2MessagesEl) return; - const filter = ft2FilterText; - const fragment = document.createDocumentFragment(); - let rendered = 0; - for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) { - const msg = ft2MessageHistory[i]; - if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; - fragment.appendChild(renderFt2Row(msg)); - rendered++; - } - ft2MessagesEl.replaceChildren(fragment); -} - -function addFt2Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft2MessageHistory.unshift(msg); - pruneFt2MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft2"); - window.updateFt8Bar?.(); - scheduleFt2HistoryRender(); -} - -function normalizeServerFt2Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; - const grids = locatorDetails.length > 0 - ? locatorDetails.map((d) => d.grid) - : (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []); - const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; - const rfHz = normalizeFt2DisplayFreqHz(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.onServerFt2Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - if (ft2Status) ft2Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt2Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft2", 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(); - ft2MessageHistory = normalized.concat(ft2MessageHistory); - pruneFt2MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft2"); - window.updateFt8Bar?.(); - scheduleFt2HistoryRender(); -}; - -window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); }; -window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); }; - -window.resetFt2HistoryView = function() { - if (ft2MessagesEl) ft2MessagesEl.innerHTML = ""; - ft2MessageHistory = []; - window.updateFt8Bar?.(); - renderFt2History(); -}; - -function buildFt2BarFrames() { - const cutoffMs = Date.now() - 15 * 60 * 1000; - const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const tsMs = msg._tsMs ?? msg.ts_ms; - const ts = tsMs ? `${fmtTime(tsMs)}` : ""; - 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 displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz); - const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8RenderMessageFt2((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames); - -if (ft2FilterInput) { - ft2FilterInput.addEventListener("input", () => { - ft2FilterText = ft2FilterInput.value.trim().toUpperCase(); - renderFt2History(); - }); -} - -const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn"); -ft2DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn); - await postPath("/toggle_ft2_decode"); - } catch (e) { - console.error("FT2 toggle failed", e); - } -}); - -document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft2_decode"); - window.resetFt2HistoryView(); - } catch (e) { console.error("FT2 history clear failed", e); } -}); - -window.onServerFt2 = function(msg) { - if (ft2Status) ft2Status.textContent = "Receiving"; - const next = normalizeServerFt2Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - addFt2Message(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.ts new file mode 100644 index 00000000..55646137 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import { initializeFtxDecoder } from "./ftx-family"; + +initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3_750 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js deleted file mode 100644 index 58a07985..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js +++ /dev/null @@ -1,207 +0,0 @@ -// --- FT4 Decoder Plugin (server-side decode) --- -// SPDX-FileCopyrightText: 2026 Stan Grams -// SPDX-License-Identifier: GPL-2.0-or-later - -function ft8RenderMessage(message) { - if (typeof renderFt8Message === "function") return renderFt8Message(message); - if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); - return message; -} - -const ft4Status = document.getElementById("ft4-status"); -const ft4PeriodEl = document.getElementById("ft4-period"); -const ft4MessagesEl = document.getElementById("ft4-messages"); -const ft4FilterInput = document.getElementById("ft4-filter"); -const FT4_PERIOD_MS = 7500; -const FT4_MAX_DOM_ROWS = 200; -let ft4FilterText = ""; -let ft4MessageHistory = []; - -function currentFt4HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneFt4MessageHistory() { - const cutoffMs = Date.now() - currentFt4HistoryRetentionMs(); - ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleFt4Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; - } - job(); -} - -function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); } - -function normalizeFt4DisplayFreqHz(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 updateFt4PeriodTimer() { - if (!ft4PeriodEl) return; - const nowMs = Date.now(); - const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000; - ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; -} - -updateFt4PeriodTimer(); -setInterval(updateFt4PeriodTimer, 250); - -function renderFt4Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft4"; - 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 = normalizeFt4DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = ft8RenderMessage(rawMessage); - const tsMs = msg._tsMs ?? msg.ts_ms; - const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; - row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; - return row; -} - -function renderFt4History() { - pruneFt4MessageHistory(); - if (!ft4MessagesEl) return; - const filter = ft4FilterText; - const fragment = document.createDocumentFragment(); - let rendered = 0; - for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) { - const msg = ft4MessageHistory[i]; - if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; - fragment.appendChild(renderFt4Row(msg)); - rendered++; - } - ft4MessagesEl.replaceChildren(fragment); -} - -function addFt4Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft4MessageHistory.unshift(msg); - pruneFt4MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft4"); - window.updateFt8Bar?.(); - scheduleFt4HistoryRender(); -} - -function normalizeServerFt4Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; - const grids = locatorDetails.length > 0 - ? locatorDetails.map((d) => d.grid) - : (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []); - const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; - const rfHz = normalizeFt4DisplayFreqHz(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.onServerFt4Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - if (ft4Status) ft4Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt4Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft4", 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(); - ft4MessageHistory = normalized.concat(ft4MessageHistory); - pruneFt4MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft4"); - window.updateFt8Bar?.(); - scheduleFt4HistoryRender(); -}; - -window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); }; -window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); }; - -window.resetFt4HistoryView = function() { - if (ft4MessagesEl) ft4MessagesEl.innerHTML = ""; - ft4MessageHistory = []; - window.updateFt8Bar?.(); - renderFt4History(); -}; - -function buildFt4BarFrames() { - const cutoffMs = Date.now() - 15 * 60 * 1000; - const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const tsMs = msg._tsMs ?? msg.ts_ms; - const ts = tsMs ? `${fmtTime(tsMs)}` : ""; - 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 displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz); - const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8RenderMessage((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames); - -if (ft4FilterInput) { - ft4FilterInput.addEventListener("input", () => { - ft4FilterText = ft4FilterInput.value.trim().toUpperCase(); - renderFt4History(); - }); -} - -const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn"); -ft4DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn); - await postPath("/toggle_ft4_decode"); - } catch (e) { - console.error("FT4 toggle failed", e); - } -}); - -document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft4_decode"); - window.resetFt4HistoryView(); - } catch (e) { console.error("FT4 history clear failed", e); } -}); - -window.onServerFt4 = function(msg) { - if (ft4Status) ft4Status.textContent = "Receiving"; - const next = normalizeServerFt4Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - addFt4Message(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.ts new file mode 100644 index 00000000..3a9a1816 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import { initializeFtxDecoder } from "./ftx-family"; + +initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7_500 }); 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 new file mode 100644 index 00000000..0c27c921 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +export type FtxDecoderId = "ft2" | "ft4"; + +interface FtxMessage { + message?: string | undefined; + ts_ms?: number | undefined; + _tsMs?: number | undefined; + snr_db?: number | undefined; + dt_s?: number | undefined; + freq_hz?: number | undefined; + receiver?: unknown; + [key: string]: unknown; +} + +interface LocatorDetails { grid: string; [key: string]: unknown } +interface BarFrames { count: number; newestTsMs: number; html: string } +interface ConfirmApi { + confirm(options: { title: string; message: string; confirmLabel: string }): Promise; +} +interface FtxBridge { + ft8BaseHz?: number; + getDecodeHistoryRetentionMs?: () => number; + trxScheduleUiFrameJob?: (key: string, job: () => void) => void; + renderFt8Message?: (message: string) => string; + ft8EscapeHtml?: (message: string) => string; + ft8ExtractLocatorDetails?: (message: string) => LocatorDetails[]; + ft8ExtractAllGrids?: (message: string) => string[]; + ft8ExtractLikelyCallsign?: (message: string) => string | null; + getDecodeRigMeta?: () => unknown; + mapAddLocator?: ( + raw: string, + grids: string[], + decoder: FtxDecoderId, + station: string | null, + message: FtxMessage, + ) => void; + setFt8FamilyBarDecoder?: (decoder: FtxDecoderId) => void; + updateFt8Bar?: () => void; + registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void; + takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; + postPath?: (path: string) => Promise; + fmtTime?: (timestampMs: number) => string; + trxUi: ConfirmApi; + [key: string]: unknown; +} + +interface FtxConfig { + id: FtxDecoderId; + label: "FT2" | "FT4"; + periodMs: number; +} + +const bridge = window as unknown as FtxBridge; + +function finiteNumber(value: unknown): number | null { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; +} + +export function initializeFtxDecoder(config: FtxConfig): void { + const { id, label, periodMs } = 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`) as HTMLInputElement | null; + let filterText = ""; + let history: FtxMessage[] = []; + + const renderMessage = (message: string): string => { + if (bridge.renderFt8Message) return bridge.renderFt8Message(message); + if (bridge.ft8EscapeHtml) return bridge.ft8EscapeHtml(message); + return message; + }; + const retentionMs = (): number => bridge.getDecodeHistoryRetentionMs?.() ?? 86_400_000; + const prune = (): void => { + const cutoff = Date.now() - retentionMs(); + history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff); + }; + const schedule = (job: () => void): void => { + if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job); + else job(); + }; + const displayFrequency = (value: unknown): number | null => { + const raw = finiteNumber(value); + if (raw === null) return null; + const base = finiteNumber(bridge.ft8BaseHz); + return base !== null && base > 0 && raw >= 0 && raw < 100_000 ? base + raw : raw; + }; + const renderRow = (message: FtxMessage): HTMLDivElement => { + 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", + }); + row.innerHTML = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; + return row; + }; + const render = (): void => { + 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; + } + messagesElement.replaceChildren(fragment); + }; + const normalize = (message: FtxMessage): FtxMessage => { + 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 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, + }); + } + 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: FtxMessage[]): void => { + 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 = (): void => { + history = []; + bridge.updateFt8Bar?.(); + render(); + }; + const barFrames = (): BarFrames => { + const recent = history + .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000) + .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 is string => 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: FtxMessage[]) => { 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: FtxMessage) => { receiveBatch([message]); }; + bridge.registerFt8FamilyBarRenderer?.(id, barFrames); + + const updatePeriod = (): void => { + if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1000).toFixed(1)}s`; + }; + updatePeriod(); + window.setInterval(updatePeriod, 250); + filterInput?.addEventListener("input", () => { filterText = filterInput.value.trim().toUpperCase(); render(); }); + 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: unknown) { 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: unknown) { console.error(`${label} history clear failed`, error); } + })(); }); +} 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 new file mode 100644 index 00000000..58e81dc6 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => { + let barRenderer; + const mapMessages = []; + const window = { + ft8BaseHz: 14_074_000, + trxUi: { confirm: async () => true }, + ft8ExtractAllGrids: () => ["JO91"], + ft8ExtractLikelyCallsign: () => "SP0ABC", + mapAddLocator: (...args) => { mapMessages.push(args); }, + registerFt8FamilyBarRenderer: (_decoder, renderer) => { barRenderer = renderer; }, + fmtTime: () => "12:00", + setInterval() { return 1; }, + }; + const context = vm.createContext({ + window, + document: { getElementById: () => null, createElement: () => ({ dataset: {} }), createDocumentFragment: () => ({ appendChild() {} }) }, + Date, + Number, + String, + Array, + console, + }); + const source = await readFile(new URL("../../assets/web/generated/ft2.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + window.onServerFt2({ message: "CQ SP0ABC JO91", freq_hz: 1_250, ts_ms: Date.now(), snr_db: -8 }); + assert.equal(mapMessages.length, 1); + 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/); +});