[feat](trx-rs): add FT2 decoder support (wired to FT4)
Mirrors the FT4 implementation across the full stack. The trx-ft8 crate wires Ft8Decoder::new_ft2() to FTX_PROTOCOL_FT4 as a placeholder pending a dedicated FT2 implementation. Changes: - trx-ft8: Ft8Decoder::new_ft2() delegates to with_protocol(Ft4) - trx-core: DecodedMessage::Ft2, AUDIO_MSG_FT2_DECODE (0x15), ft2_decode_enabled/ft2_decode_reset_seq state, SetFt2DecodeEnabled/ ResetFt2Decoder commands, protocol mapping - trx-server: DecoderHistories::ft2, run_ft2_decoder (7.5s slots), run_background_ft2_decoder, history push/replay, decoder task spawn - trx-frontend-http: ft2_history in FrontendRuntimeContext, toggle/clear endpoints, /ft2.js route, bookmark/scheduler/background decode support, DecodeHistoryPayload ft2 field - web: ft2.js plugin (3.75s period timer), FT2 subtab in index.html, FT2 map source (distinct hue), app.js dispatch, decode-history-worker HISTORY_GROUP_KEYS, bookmarks read/write/apply Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -215,12 +215,14 @@ function applyAuthRestrictions() {
|
||||
"vdes-clear-btn",
|
||||
"ft8-decode-toggle-btn",
|
||||
"ft4-decode-toggle-btn",
|
||||
"ft2-decode-toggle-btn",
|
||||
"wspr-decode-toggle-btn",
|
||||
"hf-aprs-decode-toggle-btn",
|
||||
"cw-auto",
|
||||
"aprs-clear-btn",
|
||||
"ft8-clear-btn",
|
||||
"ft4-clear-btn",
|
||||
"ft2-clear-btn",
|
||||
"wspr-clear-btn",
|
||||
"cw-clear-btn"
|
||||
];
|
||||
@@ -2856,6 +2858,13 @@ function render(update) {
|
||||
ft4ToggleBtn.style.borderColor = ft4On ? "#00d17f" : "";
|
||||
ft4ToggleBtn.style.color = ft4On ? "#00d17f" : "";
|
||||
}
|
||||
const ft2ToggleBtn = document.getElementById("ft2-decode-toggle-btn");
|
||||
if (ft2ToggleBtn) {
|
||||
const ft2On = !!update.ft2_decode_enabled;
|
||||
ft2ToggleBtn.textContent = ft2On ? "Disable FT2" : "Enable FT2";
|
||||
ft2ToggleBtn.style.borderColor = ft2On ? "#00d17f" : "";
|
||||
ft2ToggleBtn.style.color = ft2On ? "#00d17f" : "";
|
||||
}
|
||||
const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||
if (wsprToggleBtn) {
|
||||
const wsprOn = !!update.wspr_decode_enabled;
|
||||
@@ -3986,7 +3995,7 @@ const locatorMarkers = new Map();
|
||||
const decodeContactPaths = new Map();
|
||||
let selectedMapQsoKey = null;
|
||||
const mapMarkers = new Set();
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, wspr: true };
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true };
|
||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
const mapLocatorFilter = { phase: "band", bands: new Set() };
|
||||
let mapSearchFilter = "";
|
||||
@@ -4163,7 +4172,7 @@ function ensureVdesMarker(key, entry) {
|
||||
}
|
||||
|
||||
function ensureDecodeLocatorMarker(entry) {
|
||||
if (!aprsMap || !entry || entry.marker || !entry.grid || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "wspr")) return;
|
||||
if (!aprsMap || !entry || entry.marker || !entry.grid || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return;
|
||||
const bounds = maidenheadToBounds(entry.grid);
|
||||
if (!bounds) return;
|
||||
const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1);
|
||||
@@ -4245,7 +4254,7 @@ function pruneAisEntry(key, entry, cutoffMs) {
|
||||
|
||||
function pruneLocatorEntry(key, entry, cutoffMs) {
|
||||
const canRenderMap = !!aprsMap && !decodeHistoryReplayActive;
|
||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "wspr")) return true;
|
||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return true;
|
||||
if (!(entry.allStationDetails instanceof Map)) {
|
||||
entry.allStationDetails = entry.stationDetails instanceof Map
|
||||
? new Map(entry.stationDetails)
|
||||
@@ -4331,6 +4340,7 @@ function locatorSourceLabel(type) {
|
||||
if (type === "bookmark") return "Bookmarks";
|
||||
if (type === "wspr") return "WSPR";
|
||||
if (type === "ft4") return "FT4";
|
||||
if (type === "ft2") return "FT2";
|
||||
return "FT8";
|
||||
}
|
||||
|
||||
@@ -4346,7 +4356,7 @@ function locatorFilterColor(type) {
|
||||
const light = lightTheme ? 42 : 56;
|
||||
const hue = type === "bookmark"
|
||||
? hues.bookmark
|
||||
: (type === "wspr" ? hues.wspr : (type === "ft4" ? hues.ft4 : hues.ft8));
|
||||
: (type === "wspr" ? hues.wspr : (type === "ft4" ? hues.ft4 : (type === "ft2" ? hues.ft2 : hues.ft8)));
|
||||
return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`;
|
||||
}
|
||||
|
||||
@@ -4472,6 +4482,7 @@ function locatorThemeHues() {
|
||||
bookmark: wrapHue(baseHue),
|
||||
ft8: wrapHue(peakHue),
|
||||
ft4: wrapHue(peakHue + 30),
|
||||
ft2: wrapHue(peakHue + 60),
|
||||
wspr: wrapHue((waveHue + baseHue) / 2),
|
||||
bandBase: wrapHue((baseHue * 0.65) + (peakHue * 0.35)),
|
||||
};
|
||||
@@ -4522,6 +4533,7 @@ function locatorHueForEntry(entry) {
|
||||
if (entry?.sourceType === "bookmark") return hues.bookmark;
|
||||
if (entry?.sourceType === "wspr") return hues.wspr;
|
||||
if (entry?.sourceType === "ft4") return hues.ft4;
|
||||
if (entry?.sourceType === "ft2") return hues.ft2;
|
||||
return hues.ft8;
|
||||
}
|
||||
|
||||
@@ -4788,7 +4800,7 @@ function setSelectedLocatorMarker(marker) {
|
||||
|
||||
function isLocatorOverlay(marker) {
|
||||
const type = marker?.__trxType;
|
||||
return type === "bookmark" || type === "ft8" || type === "ft4" || type === "wspr";
|
||||
return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr";
|
||||
}
|
||||
|
||||
function sendLocatorOverlayToBack(marker) {
|
||||
@@ -4919,7 +4931,7 @@ function rebuildMapLocatorFilters() {
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
const sourceType = entry?.sourceType;
|
||||
if (!sourceType) continue;
|
||||
if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue;
|
||||
if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue;
|
||||
availableSources.add(sourceType);
|
||||
const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map();
|
||||
for (const [label, hz] of meta.entries()) {
|
||||
@@ -4947,7 +4959,7 @@ function rebuildMapLocatorFilters() {
|
||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||
}
|
||||
|
||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "wspr"]
|
||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"]
|
||||
.filter((key) => availableSources.has(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
@@ -4989,7 +5001,7 @@ function markerPassesLocatorFilters(marker) {
|
||||
|
||||
function markerSearchText(marker) {
|
||||
const type = marker?.__trxType;
|
||||
if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "wspr") {
|
||||
if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") {
|
||||
const entry = locatorEntryForMarker(marker);
|
||||
const parts = [];
|
||||
if (entry?.grid) parts.push(entry.grid);
|
||||
@@ -5135,7 +5147,7 @@ window.clearMapMarkersByType = function(type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "ft8" || type === "ft4" || type === "wspr") {
|
||||
if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") {
|
||||
const prefix = `${type}:`;
|
||||
for (const [key, entry] of locatorMarkers.entries()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
@@ -5382,7 +5394,7 @@ function initAprsMap() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (marker.__trxType === "bookmark" || marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "wspr") {
|
||||
if (marker.__trxType === "bookmark" || marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") {
|
||||
const center = locatorMarkerCenter(marker);
|
||||
if (center) {
|
||||
setSelectedLocatorMarker(marker);
|
||||
@@ -5621,10 +5633,10 @@ window.navigateToMapLocator = function(grid, preferredType = null) {
|
||||
sizeAprsMapToViewport();
|
||||
if (!aprsMap) return false;
|
||||
|
||||
const pref = preferredType === "wspr" ? "wspr" : (preferredType === "ft4" ? "ft4" : (preferredType === "ft8" ? "ft8" : null));
|
||||
const pref = preferredType === "wspr" ? "wspr" : (preferredType === "ft4" ? "ft4" : (preferredType === "ft2" ? "ft2" : (preferredType === "ft8" ? "ft8" : null)));
|
||||
const keys = pref
|
||||
? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]
|
||||
: [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`];
|
||||
? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]
|
||||
: [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`];
|
||||
let entry = null;
|
||||
for (const key of keys) {
|
||||
entry = locatorMarkers.get(key);
|
||||
@@ -6164,6 +6176,7 @@ function applyMapFilter() {
|
||||
(type === "aprs" && mapFilter.aprs) ||
|
||||
(type === "ft8" && mapFilter.ft8) ||
|
||||
(type === "ft4" && mapFilter.ft4) ||
|
||||
(type === "ft2" && mapFilter.ft2) ||
|
||||
(type === "wspr" && mapFilter.wspr)
|
||||
);
|
||||
const onMap = aprsMap.hasLayer(marker);
|
||||
@@ -6275,7 +6288,7 @@ function rebuildDecodeContactPaths() {
|
||||
const stationLocators = new Map();
|
||||
const directedMessages = [];
|
||||
for (const entry of locatorMarkers.values()) {
|
||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "wspr")) continue;
|
||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue;
|
||||
const grid = String(entry.grid || "").trim().toUpperCase();
|
||||
if (!grid || !(entry.stationDetails instanceof Map)) continue;
|
||||
for (const detail of entry.stationDetails.values()) {
|
||||
@@ -6524,7 +6537,7 @@ window.syncBookmarkMapLocators = function(bookmarks) {
|
||||
|
||||
window.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) {
|
||||
if (!Array.isArray(grids) || grids.length === 0) return;
|
||||
const markerType = type === "wspr" ? "wspr" : (type === "ft4" ? "ft4" : "ft8");
|
||||
const markerType = type === "wspr" ? "wspr" : (type === "ft4" ? "ft4" : (type === "ft2" ? "ft2" : "ft8"));
|
||||
const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
|
||||
const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
|
||||
const locatorDetails = new Map();
|
||||
@@ -7326,6 +7339,7 @@ function updateDecodeStatus(text) {
|
||||
const cw = document.getElementById("cw-status");
|
||||
const ft8 = document.getElementById("ft8-status");
|
||||
const ft4 = document.getElementById("ft4-status");
|
||||
const ft2 = document.getElementById("ft2-status");
|
||||
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
|
||||
const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text;
|
||||
setModeBoundDecodeStatus(vdes, ["VDES"], "Select VDES mode to decode", vdesText);
|
||||
@@ -7334,6 +7348,7 @@ function updateDecodeStatus(text) {
|
||||
setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText);
|
||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
||||
if (ft4 && ft4.textContent !== "Receiving") ft4.textContent = text;
|
||||
if (ft2 && ft2.textContent !== "Receiving") ft2.textContent = text;
|
||||
}
|
||||
function dispatchDecodeMessage(msg) {
|
||||
if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg);
|
||||
@@ -7343,6 +7358,7 @@ function dispatchDecodeMessage(msg) {
|
||||
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
|
||||
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
|
||||
if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg);
|
||||
if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg);
|
||||
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
|
||||
}
|
||||
|
||||
@@ -7375,6 +7391,10 @@ function dispatchDecodeBatch(batch) {
|
||||
window.onServerFt4Batch(batch);
|
||||
return;
|
||||
}
|
||||
if (type === "ft2" && window.onServerFt2Batch) {
|
||||
window.onServerFt2Batch(batch);
|
||||
return;
|
||||
}
|
||||
if (type === "wspr" && window.onServerWsprBatch) {
|
||||
window.onServerWsprBatch(batch);
|
||||
return;
|
||||
@@ -7449,6 +7469,10 @@ function restoreDecodeHistoryGroup(kind, messages) {
|
||||
window.restoreFt4History(messages);
|
||||
return;
|
||||
}
|
||||
if (kind === "ft2" && window.restoreFt2History) {
|
||||
window.restoreFt2History(messages);
|
||||
return;
|
||||
}
|
||||
if (kind === "wspr" && window.restoreWsprHistory) {
|
||||
window.restoreWsprHistory(messages);
|
||||
return;
|
||||
@@ -7537,7 +7561,7 @@ function connectDecode() {
|
||||
|
||||
function totalDecodeHistoryMessages(groups) {
|
||||
if (!groups || typeof groups !== "object") return 0;
|
||||
return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "wspr"]
|
||||
return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]
|
||||
.reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0);
|
||||
}
|
||||
|
||||
@@ -7548,7 +7572,7 @@ function connectDecode() {
|
||||
setDecodeHistoryReplayActive(true);
|
||||
updateHistoryReplayOverlay();
|
||||
}
|
||||
for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "wspr"]) {
|
||||
for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]) {
|
||||
const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : [];
|
||||
if (messages.length === 0) continue;
|
||||
for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "wspr"];
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"];
|
||||
|
||||
function decodeCborUint(view, bytes, state, additional) {
|
||||
const offset = state.offset;
|
||||
|
||||
@@ -375,6 +375,7 @@
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ais" value="ais" /> AIS</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft8" value="ft8" /> FT8</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft4" value="ft4" /> FT4</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-ft2" value="ft2" /> FT2</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-wspr" value="wspr" /> WSPR</label>
|
||||
<label class="bm-decoder-check"><input type="checkbox" id="bm-dec-hf-aprs" value="hf-aprs" /> HF APRS</label>
|
||||
</div>
|
||||
@@ -427,6 +428,7 @@
|
||||
<button class="sub-tab" data-subtab="cw">CW</button>
|
||||
<button class="sub-tab" data-subtab="ft8">FT8</button>
|
||||
<button class="sub-tab" data-subtab="ft4">FT4</button>
|
||||
<button class="sub-tab" data-subtab="ft2">FT2</button>
|
||||
<button class="sub-tab" data-subtab="wspr">WSPR</button>
|
||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||
</div>
|
||||
@@ -467,6 +469,12 @@
|
||||
Decodes FT4 messages from RX audio (DIG/USB only, toggle required). 7.5-second slots.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item">
|
||||
<strong>FT2 Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Decodes FT2 messages from RX audio (DIG/USB only, toggle required). 3.75-second slots.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item">
|
||||
<strong>WSPR Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
@@ -660,6 +668,24 @@
|
||||
</div>
|
||||
<div id="ft4-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-ft2" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<button id="ft2-decode-toggle-btn" type="button">Enable FT2</button>
|
||||
<button id="ft2-pause-btn" type="button">Pause</button>
|
||||
<button id="ft2-clear-btn" type="button">Clear</button>
|
||||
<input id="ft2-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. CQ, DL4)" />
|
||||
<small id="ft2-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
<small id="ft2-period" style="color:var(--text-muted);">Next slot --s</small>
|
||||
</div>
|
||||
<div class="ft8-header">
|
||||
<span class="ft8-time">Time</span>
|
||||
<span class="ft8-snr">SNR</span>
|
||||
<span class="ft8-dt">DT</span>
|
||||
<span class="ft8-freq">RF</span>
|
||||
<span class="ft8-msg">Message</span>
|
||||
</div>
|
||||
<div id="ft2-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-wspr" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<button id="wspr-decode-toggle-btn" type="button">Enable WSPR</button>
|
||||
@@ -937,6 +963,7 @@
|
||||
<script src="/hf-aprs.js"></script>
|
||||
<script src="/ft8.js"></script>
|
||||
<script src="/ft4.js"></script>
|
||||
<script src="/ft2.js"></script>
|
||||
<script src="/wspr.js"></script>
|
||||
<script src="/cw.js"></script>
|
||||
<script src="/bookmarks.js"></script>
|
||||
|
||||
@@ -183,6 +183,7 @@ function bmReadDecoders() {
|
||||
if (document.getElementById("bm-dec-ais").checked) decoders.push("ais");
|
||||
if (document.getElementById("bm-dec-ft8").checked) decoders.push("ft8");
|
||||
if (document.getElementById("bm-dec-ft4").checked) decoders.push("ft4");
|
||||
if (document.getElementById("bm-dec-ft2").checked) decoders.push("ft2");
|
||||
if (document.getElementById("bm-dec-wspr").checked) decoders.push("wspr");
|
||||
if (document.getElementById("bm-dec-hf-aprs").checked) decoders.push("hf-aprs");
|
||||
return decoders;
|
||||
@@ -195,6 +196,7 @@ function bmWriteDecoders(decoders) {
|
||||
document.getElementById("bm-dec-ais").checked = list.includes("ais");
|
||||
document.getElementById("bm-dec-ft8").checked = list.includes("ft8");
|
||||
document.getElementById("bm-dec-ft4").checked = list.includes("ft4");
|
||||
document.getElementById("bm-dec-ft2").checked = list.includes("ft2");
|
||||
document.getElementById("bm-dec-wspr").checked = list.includes("wspr");
|
||||
document.getElementById("bm-dec-hf-aprs").checked = list.includes("hf-aprs");
|
||||
}
|
||||
@@ -358,6 +360,10 @@ async function bmApply(bm) {
|
||||
if (wantFt4 !== !!st.ft4_decode_enabled) {
|
||||
await postPath("/toggle_ft4_decode");
|
||||
}
|
||||
const wantFt2 = bm.decoders.includes("ft2");
|
||||
if (wantFt2 !== !!st.ft2_decode_enabled) {
|
||||
await postPath("/toggle_ft2_decode");
|
||||
}
|
||||
const wantWspr = bm.decoders.includes("wspr");
|
||||
if (wantWspr !== !!st.wspr_decode_enabled) {
|
||||
await postPath("/toggle_wspr_decode");
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// --- FT2 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
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 ft2PauseBtn = document.getElementById("ft2-pause-btn");
|
||||
const FT2_PERIOD_MS = 3750;
|
||||
let ft2FilterText = "";
|
||||
let ft2MessageHistory = [];
|
||||
let ft2Paused = false;
|
||||
let ft2BufferedWhilePaused = 0;
|
||||
|
||||
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 updateFt2PauseUi() {
|
||||
if (!ft2PauseBtn) return;
|
||||
ft2PauseBtn.textContent = ft2Paused ? "Resume" : "Pause";
|
||||
ft2PauseBtn.classList.toggle("active", ft2Paused);
|
||||
}
|
||||
|
||||
function renderFt2History() {
|
||||
pruneFt2MessageHistory();
|
||||
if (!ft2MessagesEl || ft2Paused) { updateFt2PauseUi(); return; }
|
||||
const filter = ft2FilterText;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < ft2MessageHistory.length; i++) {
|
||||
const msg = ft2MessageHistory[i];
|
||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||
fragment.appendChild(renderFt2Row(msg));
|
||||
}
|
||||
ft2MessagesEl.replaceChildren(fragment);
|
||||
updateFt2PauseUi();
|
||||
}
|
||||
|
||||
function addFt2Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft2MessageHistory.unshift(msg);
|
||||
pruneFt2MessageHistory();
|
||||
if (ft2Paused) { ft2BufferedWhilePaused += 1; updateFt2PauseUi(); return; }
|
||||
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 = ft2Paused ? "Paused" : "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();
|
||||
if (ft2Paused) { ft2BufferedWhilePaused += messages.length; updateFt2PauseUi(); return; }
|
||||
scheduleFt2HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
|
||||
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
|
||||
|
||||
window.resetFt2HistoryView = function() {
|
||||
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
||||
ft2MessageHistory = [];
|
||||
ft2BufferedWhilePaused = 0;
|
||||
renderFt2History();
|
||||
};
|
||||
|
||||
if (ft2FilterInput) {
|
||||
ft2FilterInput.addEventListener("input", () => {
|
||||
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
||||
renderFt2History();
|
||||
});
|
||||
}
|
||||
|
||||
if (ft2PauseBtn) {
|
||||
ft2PauseBtn.addEventListener("click", () => {
|
||||
ft2Paused = !ft2Paused;
|
||||
if (!ft2Paused) { ft2BufferedWhilePaused = 0; renderFt2History(); } else { updateFt2PauseUi(); }
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("ft2-decode-toggle-btn")?.addEventListener("click", async () => {
|
||||
try { await postPath("/toggle_ft2_decode"); } catch (e) { console.error("FT2 toggle failed", e); }
|
||||
});
|
||||
|
||||
document.getElementById("ft2-clear-btn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await postPath("/clear_ft2_decode");
|
||||
window.resetFt2HistoryView();
|
||||
} catch (e) { console.error("FT2 clear failed", e); }
|
||||
});
|
||||
|
||||
window.onServerFt2 = function(msg) {
|
||||
if (ft2Status) ft2Status.textContent = ft2Paused ? "Paused" : "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);
|
||||
};
|
||||
|
||||
updateFt2PauseUi();
|
||||
Reference in New Issue
Block a user