refactor: consolidate FT2 and FT4 plugins in TypeScript
This commit is contained in:
@@ -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 = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||
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 ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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 = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
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 ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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 });
|
||||
})();
|
||||
|
||||
@@ -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 = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||
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 ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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 = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
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 ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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 });
|
||||
})();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// --- FT2 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
// 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 = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
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 ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { initializeFtxDecoder } from "./ftx-family";
|
||||
|
||||
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3_750 });
|
||||
@@ -1,207 +0,0 @@
|
||||
// --- FT4 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
// 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 = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
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 ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { initializeFtxDecoder } from "./ftx-family";
|
||||
|
||||
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7_500 });
|
||||
@@ -0,0 +1,203 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// 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<boolean>;
|
||||
}
|
||||
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<void>;
|
||||
postPath?: (path: string) => Promise<unknown>;
|
||||
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 = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||
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 ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
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); }
|
||||
})(); });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
test("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/);
|
||||
});
|
||||
Reference in New Issue
Block a user