[feat](trx-frontend-http): add background decode settings

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-12 22:42:50 +01:00
parent 21a534bdb6
commit 2462f1dd47
8 changed files with 1053 additions and 10 deletions
@@ -3606,7 +3606,7 @@ async function initializeApp() {
updateAuthUI();
connect();
connectDecode();
initSchedulerUI();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
return;
@@ -3620,7 +3620,7 @@ async function initializeApp() {
applyAuthRestrictions();
connect();
connectDecode();
initSchedulerUI();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
@@ -3631,11 +3631,15 @@ async function initializeApp() {
}
}
function initSchedulerUI() {
function initSettingsUI() {
if (typeof initScheduler === "function") {
initScheduler(lastActiveRigId, authRole);
wireSchedulerEvents();
}
if (typeof initBackgroundDecode === "function") {
initBackgroundDecode(lastActiveRigId, authRole);
wireBackgroundDecodeEvents();
}
}
// Setup auth form
@@ -3655,7 +3659,7 @@ document.getElementById("auth-form").addEventListener("submit", async (e) => {
applyAuthRestrictions();
connect();
connectDecode();
initSchedulerUI();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
@@ -3678,7 +3682,7 @@ if (guestBtn) {
applyAuthRestrictions();
connect();
connectDecode();
initSchedulerUI();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
@@ -674,6 +674,7 @@
<div id="tab-settings" class="tab-panel" style="display:none;">
<div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
</div>
<div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel">
@@ -779,6 +780,44 @@
</div>
</div>
</div>
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
<div id="background-decode-panel" class="sch-panel">
<div class="sch-toast" id="background-decode-toast" style="display:none;"></div>
<div class="sch-section">
<div class="sch-section-title">Configuration</div>
<div class="sch-row">
<label class="sch-label">Rig
<select id="background-decode-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
</label>
<label class="sch-label bgd-toggle-wrap">Background decode
<span class="bgd-toggle-row">
<input type="checkbox" id="background-decode-enabled" />
<span>Enable hidden background decoder channels</span>
</span>
</label>
</div>
<div class="sch-row">
<label class="sch-label bgd-bookmark-pick">Bookmark
<div class="bgd-add-row">
<select id="background-decode-bookmark-pick" class="status-input" aria-label="Background decode bookmark"></select>
<button id="background-decode-bookmark-add" type="button" class="sch-write">+ Add</button>
</div>
</label>
</div>
<div id="background-decode-bookmark-list" class="bgd-bookmark-list"></div>
</div>
<div class="sch-actions">
<button id="background-decode-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
<button id="background-decode-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset</button>
</div>
<div class="sch-section">
<div class="sch-section-title">Runtime Status</div>
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
</div>
</div>
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
@@ -819,6 +858,7 @@
<script src="/cw.js"></script>
<script src="/bookmarks.js"></script>
<script src="/scheduler.js"></script>
<script src="/background-decode.js"></script>
<script src="/vchan.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/leaflet-ais-tracksymbol.js"></script>
@@ -0,0 +1,355 @@
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
(function () {
"use strict";
const SUPPORTED_DECODERS = ["ft8", "wspr", "hf-aprs"];
let backgroundDecodeRole = null;
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
let statusInterval = null;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
currentRigId = rigId || null;
renderRigSelect();
loadBackgroundDecode();
startStatusPolling();
}
function renderRigSelect() {
const sel = document.getElementById("background-decode-rig-select");
if (!sel) return;
const rigs = typeof getAvailableRigIds === "function" ? getAvailableRigIds() : [];
if (!rigs.length) return;
sel.innerHTML = "";
rigs.forEach(function (rigId) {
const opt = document.createElement("option");
opt.value = rigId;
opt.textContent = rigId;
if (rigId === currentRigId) opt.selected = true;
sel.appendChild(opt);
});
if (!currentRigId || !rigs.includes(currentRigId)) {
currentRigId = rigs[0];
sel.value = currentRigId;
}
}
function apiGetConfig(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiPutConfig(rigId, config) {
return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiResetConfig(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "DELETE",
}).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiGetStatus(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function apiGetBookmarks() {
return fetch("/bookmarks").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function loadBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
.then(function ([config, bookmarks]) {
currentConfig = config || { rig_id: rigId, enabled: false, bookmark_ids: [] };
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
renderBookmarkPick();
renderBackgroundDecode();
pollBackgroundDecodeStatus();
})
.catch(function (err) {
console.error("background decode load failed", err);
});
}
function supportedBookmarks() {
return bookmarkList.filter(function (bookmark) {
return bookmarkDecoderKinds(bookmark).length > 0;
});
}
function bookmarkDecoderKinds(bookmark) {
const decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
return decoders
.map(function (item) { return String(item || "").trim().toLowerCase(); })
.filter(function (item, index, arr) {
return SUPPORTED_DECODERS.includes(item) && arr.indexOf(item) === index;
});
}
function renderBookmarkPick() {
const sel = document.getElementById("background-decode-bookmark-pick");
if (!sel) return;
const selectedIds = new Set(currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []);
sel.innerHTML = '<option value="">- select bookmark -</option>';
supportedBookmarks().forEach(function (bookmark) {
if (selectedIds.has(bookmark.id)) return;
const opt = document.createElement("option");
opt.value = bookmark.id;
opt.textContent = bookmark.name + " (" + formatFreq(bookmark.freq_hz) + " " + bookmark.mode + ")";
sel.appendChild(opt);
});
}
function renderBackgroundDecode() {
if (!currentConfig) {
currentConfig = { rig_id: currentRigId, enabled: false, bookmark_ids: [] };
}
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
renderBookmarkList();
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
el.disabled = !isControl;
});
}
const saveBtn = document.getElementById("background-decode-save-btn");
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
}
function renderBookmarkList() {
const container = document.getElementById("background-decode-bookmark-list");
if (!container) return;
container.innerHTML = "";
const ids = currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : [];
if (!ids.length) {
container.textContent = "No background decode bookmarks selected.";
return;
}
ids.forEach(function (id) {
const bookmark = bookmarkList.find(function (item) { return item.id === id; });
const chip = document.createElement("div");
chip.className = "bgd-bookmark-chip";
const decoders = bookmarkDecoderKinds(bookmark);
chip.innerHTML =
'<span>' + escHtml(bookmark ? bookmark.name : id) + '</span>' +
'<span class="bgd-bookmark-chip-meta">' + escHtml(bookmark ? (formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) : "Missing bookmark") + '</span>';
const btn = document.createElement("button");
btn.type = "button";
btn.className = "bgd-bookmark-chip-remove sch-write";
btn.textContent = "×";
btn.addEventListener("click", function () {
removeBookmark(id);
});
chip.appendChild(btn);
container.appendChild(chip);
});
}
function removeBookmark(id) {
if (!currentConfig || !Array.isArray(currentConfig.bookmark_ids)) return;
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (item) { return item !== id; });
renderBookmarkPick();
renderBackgroundDecode();
}
function addBookmark() {
const sel = document.getElementById("background-decode-bookmark-pick");
if (!sel || !sel.value) return;
if (!currentConfig) {
currentConfig = { rig_id: currentRigId, enabled: false, bookmark_ids: [] };
}
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
if (!currentConfig.bookmark_ids.includes(sel.value)) currentConfig.bookmark_ids.push(sel.value);
sel.value = "";
renderBookmarkPick();
renderBackgroundDecode();
}
function saveBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
const payload = {
rig_id: rigId,
enabled: !!document.getElementById("background-decode-enabled").checked,
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
};
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.disabled = true;
apiPutConfig(rigId, payload)
.then(function (saved) {
currentConfig = saved;
renderBookmarkPick();
renderBackgroundDecode();
pollBackgroundDecodeStatus();
showToast("Background decode saved.");
})
.catch(function (err) {
showToast("Save failed: " + err.message, true);
})
.finally(function () {
if (btn) btn.disabled = false;
});
}
function resetBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
apiResetConfig(rigId)
.then(function (saved) {
currentConfig = saved;
renderBookmarkPick();
renderBackgroundDecode();
pollBackgroundDecodeStatus();
showToast("Background decode reset.");
})
.catch(function (err) {
showToast("Reset failed: " + err.message, true);
});
}
function startStatusPolling() {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
}
function pollBackgroundDecodeStatus() {
const rigId = currentRigId;
if (!rigId) return;
apiGetStatus(rigId)
.then(renderStatus)
.catch(function () {});
}
function renderStatus(status) {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = Array.isArray(status && status.entries) ? status.entries : [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
const summary = [];
if (status.active_rig) {
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html +=
'<div class="bgd-status-row">' +
'<div>' +
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
'</div>' +
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' + escHtml(prettyState(entry.state)) + '</div>' +
'</div>';
});
html += "</div>";
card.innerHTML = html;
}
function prettyState(state) {
switch (state) {
case "active": return "Active";
case "out_of_span": return "Out of span";
case "waiting_for_spectrum": return "Waiting";
case "missing_bookmark": return "Missing";
case "no_supported_decoders": return "Unsupported";
case "disabled": return "Disabled";
default: return "Inactive";
}
}
function setCheckbox(id, value) {
const el = document.getElementById(id);
if (el) el.checked = !!value;
}
function formatFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
return hz + " Hz";
}
function escHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
if (!el) return;
el.textContent = msg;
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
el.style.display = "block";
setTimeout(function () {
el.style.display = "none";
}, 3000);
}
function wireBackgroundDecodeEvents() {
const rigSel = document.getElementById("background-decode-rig-select");
if (rigSel) {
rigSel.addEventListener("change", function () {
currentRigId = rigSel.value;
loadBackgroundDecode();
});
}
const addBtn = document.getElementById("background-decode-bookmark-add");
if (addBtn) addBtn.addEventListener("click", addBookmark);
const saveBtn = document.getElementById("background-decode-save-btn");
if (saveBtn) saveBtn.addEventListener("click", saveBackgroundDecode);
const resetBtn = document.getElementById("background-decode-reset-btn");
if (resetBtn) resetBtn.addEventListener("click", resetBackgroundDecode);
}
window.initBackgroundDecode = initBackgroundDecode;
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
})();
@@ -3491,6 +3491,94 @@ button:focus-visible, input:focus-visible, select:focus-visible {
line-height: 1;
}
.sch-extra-bm-rm:hover { opacity: 1; }
.bgd-toggle-wrap {
min-width: 18rem;
}
.bgd-toggle-row {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: var(--control-height);
color: var(--text);
font-weight: 500;
}
.bgd-add-row {
display: flex;
gap: 0.55rem;
align-items: center;
}
.bgd-bookmark-pick {
min-width: min(34rem, 100%);
}
.bgd-bookmark-list {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.bgd-bookmark-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--border-light);
background: var(--btn-bg);
color: var(--text);
font-size: 0.85rem;
}
.bgd-bookmark-chip-meta {
color: var(--text-muted);
font-size: 0.78rem;
}
.bgd-bookmark-chip-remove {
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0;
height: auto;
}
.bgd-bookmark-chip-remove:hover {
color: var(--text);
}
.bgd-status-list {
display: grid;
gap: 0.65rem;
}
.bgd-status-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border-light);
border-radius: 0.55rem;
background: color-mix(in srgb, var(--card-bg) 74%, transparent);
}
.bgd-status-name {
font-weight: 600;
}
.bgd-status-meta {
color: var(--text-muted);
font-size: 0.82rem;
}
.bgd-status-state {
align-self: center;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent-green);
}
.bgd-status-state[data-state="out_of_span"],
.bgd-status-state[data-state="waiting_for_spectrum"],
.bgd-status-state[data-state="inactive"] {
color: var(--accent-yellow);
}
.bgd-status-state[data-state="missing_bookmark"],
.bgd-status-state[data-state="no_supported_decoders"] {
color: var(--accent-red);
}
@media (max-width: 600px) {
.sch-row {
flex-direction: column;
@@ -3498,4 +3586,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.sch-label {
min-width: 100%;
}
.bgd-add-row,
.bgd-status-row {
flex-direction: column;
align-items: stretch;
}
}