diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js index 8fd3ae18..13bd3352 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js @@ -10,7 +10,7 @@ const pluginGroups = { }; const loaded = /* @__PURE__ */ new Set(); const loading = /* @__PURE__ */ new Map(); -const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"]); +const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js"]); function loadLegacyScript(path) { return new Promise((resolve, reject) => { const script = document.createElement("script"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js index aed696e1..41be5773 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -1,453 +1,459 @@ "use strict"; -let vchanSessionId = null; -let vchanRigId = null; -let vchanChannels = []; -let vchanActiveId = null; -let schedulerReleaseState = null; -let schedulerReleasePollTimer = null; -function vchanFmtFreq(hz) { - if (!Number.isFinite(hz) || hz <= 0) return "--"; - if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz"; - if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz"; - if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; - return hz + " Hz"; -} -function schedulerReleaseSummaryText(state) { - if (!state) return "Scheduler is controlling the rig."; - const connected = Number(state.connected_sessions) || 0; - const released = Number(state.released_sessions) || 0; - if (connected === 0) return "Scheduler can control the rig."; - if (state.all_released) { - return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`; +(() => { + // src/plugins/vchan.ts + var vchanWindow = window; + var vchanSessionId = null; + var vchanRigId = null; + var vchanChannels = []; + var vchanActiveId = null; + var schedulerReleaseState = null; + var schedulerReleasePollTimer = null; + function vchanFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; + return `${String(hz)} Hz`; } - if (!state.current_session_released) { - const othersReleased = Math.max(released, 0); - return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; - } - const blocking = Math.max(connected - released, 0); - return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; -} -function vchanRenderSchedulerRelease() { - const btn = document.getElementById("scheduler-release-btn"); - const status = document.getElementById("scheduler-release-status"); - if (!btn || !status) return; - const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); - btn.disabled = !vchanSessionId || currentReleased; - btn.classList.toggle("active", !currentReleased); - btn.textContent = "Release to Scheduler"; - status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); -} -async function vchanPollSchedulerRelease() { - if (!vchanSessionId) { - schedulerReleaseState = null; - vchanRenderSchedulerRelease(); - return; - } - try { - const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - schedulerReleaseState = await resp.json(); - vchanRenderSchedulerRelease(); - } catch (e) { - console.error("scheduler release status failed", e); - } -} -function vchanStartSchedulerReleasePolling() { - if (schedulerReleasePollTimer) { - clearInterval(schedulerReleasePollTimer); - } - schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 1e4); -} -async function vchanToggleSchedulerRelease() { - if (!vchanSessionId) return; - const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null); - try { - const resp = await fetch("/scheduler-control", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }) - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - schedulerReleaseState = await resp.json(); - vchanRenderSchedulerRelease(); - } catch (e) { - console.error("scheduler release toggle failed", e); - } -} -async function vchanTakeSchedulerControl() { - if (!vchanSessionId) return; - if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return; - try { - const resp = await fetch("/scheduler-control", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId, released: false }) - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - schedulerReleaseState = await resp.json(); - vchanRenderSchedulerRelease(); - } catch (e) { - console.error("scheduler control takeover failed", e); - } -} -window.vchanTakeSchedulerControl = vchanTakeSchedulerControl; -function vchanHandleSession(data) { - try { - const d = JSON.parse(data); - vchanSessionId = d.session_id || null; - vchanPollSchedulerRelease(); - } catch (e) { - console.warn("vchan: bad session event", e); - } -} -function vchanHandleChannels(data) { - try { - const d = JSON.parse(data); - vchanRigId = d.remote || null; - vchanChannels = d.channels || []; - const ids = new Set(vchanChannels.map((c) => c.id)); - if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) { - vchanAutoJoinPrimary(vchanChannels[0].id); - } else if (vchanActiveId && !ids.has(vchanActiveId)) { - vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null; - vchanReconnectAudio(); + function schedulerReleaseSummaryText(state) { + if (!state) return "Scheduler is controlling the rig."; + const connected = Number(state.connected_sessions) || 0; + const released = Number(state.released_sessions) || 0; + if (connected === 0) return "Scheduler can control the rig."; + if (state.all_released) { + return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`; } - vchanRender(); - vchanRenderSchedulerRelease(); - if (typeof renderRdsOverlays === "function") renderRdsOverlays(); - } catch (e) { - console.warn("vchan: bad channels event", e); + if (!state.current_session_released) { + const othersReleased = Math.max(released, 0); + return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; + } + const blocking = Math.max(connected - released, 0); + return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; } -} -function vchanRender() { - const picker = document.getElementById("vchan-picker"); - if (!picker) return; - picker.innerHTML = ""; - vchanChannels.forEach((ch) => { - const btn = document.createElement("button"); - btn.type = "button"; - btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`; - if (ch.id === vchanActiveId) btn.classList.add("active"); - const label = document.createElement("span"); - label.className = "vchan-label"; - label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`; - btn.appendChild(label); - if (!ch.permanent) { - const del = document.createElement("span"); - del.className = "vchan-del"; - del.textContent = "×"; - del.title = "Delete channel"; - del.addEventListener("click", (e) => { - e.stopPropagation(); - vchanDelete(ch.id); + function vchanRenderSchedulerRelease() { + const btn = document.getElementById("scheduler-release-btn"); + const status = document.getElementById("scheduler-release-status"); + if (!btn || !status) return; + const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); + btn.disabled = !vchanSessionId || currentReleased; + btn.classList.toggle("active", !currentReleased); + btn.textContent = "Release to Scheduler"; + status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); + } + async function vchanPollSchedulerRelease() { + if (!vchanSessionId) { + schedulerReleaseState = null; + vchanRenderSchedulerRelease(); + return; + } + try { + const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release status failed", e); + } + } + function vchanStartSchedulerReleasePolling() { + if (schedulerReleasePollTimer) { + clearInterval(schedulerReleasePollTimer); + } + schedulerReleasePollTimer = setInterval(() => { + void vchanPollSchedulerRelease(); + }, 1e4); + } + async function vchanToggleSchedulerRelease() { + if (!vchanSessionId) return; + const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; + try { + const resp = await fetch("/scheduler-control", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }) }); - btn.appendChild(del); - } - btn.addEventListener("click", () => { - if (ch.id !== vchanActiveId) vchanSubscribe(ch.id); - }); - picker.appendChild(btn); - }); - const addBtn = document.createElement("button"); - addBtn.type = "button"; - addBtn.className = "vchan-add"; - addBtn.textContent = "+"; - addBtn.title = "Allocate new virtual channel at current frequency"; - addBtn.addEventListener("click", vchanAllocate); - picker.appendChild(addBtn); - vchanSyncAccentUI(); - if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { - updateDocumentTitle(activeChannelRds()); - } - vchanRenderSchedulerRelease(); -} -async function vchanAllocate() { - if (!vchanSessionId || !vchanRigId) return; - const freqHz = typeof lastFreqHz === "number" && lastFreqHz > 0 ? lastFreqHz : 0; - const modeEl = document.getElementById("mode"); - const mode = modeEl ? modeEl.value || "USB" : "USB"; - try { - const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }) - }); - if (!resp.ok) { - const msg = await resp.text().catch(() => String(resp.status)); - console.warn("vchan: allocate failed —", msg); - return; - } - const ch = await resp.json(); - vchanActiveId = ch.id; - vchanRender(); - vchanReconnectAudio(); - } catch (e) { - console.error("vchan: allocate error", e); - } -} -async function vchanDelete(channelId) { - if (!vchanRigId) return; - try { - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`, - { method: "DELETE" } - ); - if (!resp.ok) { - console.warn("vchan: delete failed", resp.status); - } - } catch (e) { - console.error("vchan: delete error", e); - } -} -async function vchanAutoJoinPrimary(channelId) { - if (!vchanSessionId || !vchanRigId) return; - try { - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId }) - } - ); - if (!resp.ok) { - console.warn("vchan: auto-join primary failed", resp.status); - return; - } - vchanActiveId = channelId; - vchanRender(); - } catch (e) { - console.error("vchan: auto-join error", e); - } -} -async function vchanSubscribe(channelId) { - if (!vchanSessionId || !vchanRigId) return; - try { - await vchanTakeSchedulerControl(); - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId }) - } - ); - if (!resp.ok) { - console.warn("vchan: subscribe failed", resp.status); - return; - } - vchanActiveId = channelId; - vchanRender(); - vchanSyncModeDisplay(); - vchanReconnectAudio(); - } catch (e) { - console.error("vchan: subscribe error", e); - } -} -function vchanReconnectAudio() { - const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; - if (typeof _audioChannelOverride !== "undefined") { - _audioChannelOverride = ch ? ch.id : null; - } - if (typeof rxActive === "undefined" || !rxActive) return; - if (typeof stopRxAudio === "function") stopRxAudio(); - setTimeout(() => { - if (typeof startRxAudio === "function") startRxAudio(); - }, 300); -} -function vchanApplyCapabilities(caps) { - const picker = document.getElementById("vchan-picker"); - if (!picker) return; - picker.style.display = caps && caps.filter_controls ? "" : "none"; - vchanRenderSchedulerRelease(); -} -function vchanIsOnVirtual() { - if (!vchanActiveId || vchanChannels.length === 0) return false; - return vchanActiveId !== vchanChannels[0].id; -} -function vchanActiveChannel() { - return vchanChannels.find((c) => c.id === vchanActiveId) || null; -} -function vchanUpdateFreqDisplay() { - const ch = vchanActiveChannel(); - if (!ch) return; - const el = document.getElementById("freq"); - if (!el) return; - if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") { - el.value = formatFreqForStep(ch.freq_hz, jogUnit); - } else { - el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); - } -} -function vchanSyncModeDisplay() { - const modeEl = document.getElementById("mode"); - if (!modeEl) return; - if (vchanIsOnVirtual()) { - const ch = vchanActiveChannel(); - if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); - } - const modeUpper = (modeEl.value || "").toUpperCase(); - if (typeof lastModeName !== "undefined") { - if (modeUpper === "WFM" && lastModeName !== "WFM") { - if (typeof setJogDivisor === "function") setJogDivisor(10); - if (typeof resetRdsDisplay === "function") resetRdsDisplay(); - } else if (modeUpper !== "WFM" && lastModeName === "WFM") { - if (typeof resetRdsDisplay === "function") resetRdsDisplay(); - } - lastModeName = modeUpper; - } - if (typeof updateWfmControls === "function") updateWfmControls(); - if (typeof updateSdrSquelchControlVisibility === "function") { - updateSdrSquelchControlVisibility(); - } - if (typeof refreshRdsUi === "function") { - refreshRdsUi(); - } else if (typeof positionRdsPsOverlay === "function") { - positionRdsPsOverlay(); - } -} -function vchanSyncBwDisplay() { - if (!vchanIsOnVirtual()) return; - const ch = vchanActiveChannel(); - if (!ch) return; - const bwEl = document.getElementById("spectrum-bw-input"); - if (!bwEl) return; - let bwHz = ch.bandwidth_hz || 0; - if (bwHz === 0 && typeof mwDefaultsForMode === "function") { - bwHz = mwDefaultsForMode(ch.mode)[0] || 0; - } - if (bwHz > 0) { - bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); - if (typeof currentBandwidthHz !== "undefined") { - currentBandwidthHz = bwHz; - window.currentBandwidthHz = bwHz; - } else { - window.currentBandwidthHz = bwHz; + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release toggle failed", e); } } -} -function vchanSyncAccentUI() { - const onVirtual = vchanIsOnVirtual(); - const freqEl = document.getElementById("freq"); - const bwEl = document.getElementById("spectrum-bw-input"); - if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual); - if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual); - if (onVirtual) { - vchanUpdateFreqDisplay(); - vchanSyncModeDisplay(); - vchanSyncBwDisplay(); - } else if (typeof _origRefreshFreqDisplay === "function") { - _origRefreshFreqDisplay(); - } - if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { - updateDocumentTitle(activeChannelRds()); - } -} -let _origRefreshFreqDisplay = null; -function vchanSetChannelFreq(freqHz) { - if (!vchanRigId || !vchanActiveId) return; - if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && lastSpectrumData.sample_rate > 0) { - const halfSpan = Number(lastSpectrumData.sample_rate) / 2; - const center = Number(lastSpectrumData.center_hz); - if (Math.abs(freqHz - center) > halfSpan) { - if (typeof showHint === "function") { - showHint( - `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, - 3e3 - ); - } - return; - } - } - vchanTakeSchedulerControl(); - fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ freq_hz: Math.round(freqHz) }) - } - ).catch((e) => console.error("vchan: set freq error", e)); -} -async function vchanSetChannelBandwidth(bwHz) { - if (!vchanRigId || !vchanActiveId) return; - try { - await vchanTakeSchedulerControl(); - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, - { + async function vchanTakeSchedulerControl() { + if (!vchanSessionId) return; + if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return; + try { + const resp = await fetch("/scheduler-control", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }) - } - ); - if (!resp.ok) console.warn("vchan: set bw failed", resp.status); - } catch (e) { - console.error("vchan: set bw error", e); + body: JSON.stringify({ session_id: vchanSessionId, released: false }) + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler control takeover failed", e); + } } -} -async function vchanSetChannelMode(mode) { - if (!vchanRigId || !vchanActiveId) return; - try { - await vchanTakeSchedulerControl(); - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`, - { - method: "PUT", + vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl; + function vchanHandleSession(data) { + try { + const d = JSON.parse(data); + vchanSessionId = d.session_id || null; + void vchanPollSchedulerRelease(); + } catch (e) { + console.warn("vchan: bad session event", e); + } + } + function vchanHandleChannels(data) { + try { + const d = JSON.parse(data); + vchanRigId = d.remote || null; + vchanChannels = d.channels || []; + const ids = new Set(vchanChannels.map((c) => c.id)); + const primaryChannel = vchanChannels[0]; + if (!vchanActiveId && primaryChannel && vchanSessionId) { + void vchanAutoJoinPrimary(primaryChannel.id); + } else if (vchanActiveId && !ids.has(vchanActiveId)) { + vchanActiveId = vchanChannels[0]?.id ?? null; + vchanReconnectAudio(); + } + vchanRender(); + vchanRenderSchedulerRelease(); + vchanWindow.renderRdsOverlays?.(); + } catch (e) { + console.warn("vchan: bad channels event", e); + } + } + vchanWindow.vchanHandleSession = vchanHandleSession; + vchanWindow.vchanHandleChannels = vchanHandleChannels; + function vchanRender() { + const picker = document.getElementById("vchan-picker"); + if (!picker) return; + picker.innerHTML = ""; + vchanChannels.forEach((ch) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`; + if (ch.id === vchanActiveId) btn.classList.add("active"); + const label = document.createElement("span"); + label.className = "vchan-label"; + label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`; + btn.appendChild(label); + if (!ch.permanent) { + const del = document.createElement("span"); + del.className = "vchan-del"; + del.textContent = "×"; + del.title = "Delete channel"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + void vchanDelete(ch.id); + }); + btn.appendChild(del); + } + btn.addEventListener("click", () => { + if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id); + }); + picker.appendChild(btn); + }); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "vchan-add"; + addBtn.textContent = "+"; + addBtn.title = "Allocate new virtual channel at current frequency"; + addBtn.addEventListener("click", () => { + void vchanAllocate(); + }); + picker.appendChild(addBtn); + vchanSyncAccentUI(); + if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { + vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); + } + vchanRenderSchedulerRelease(); + } + async function vchanAllocate() { + if (!vchanSessionId || !vchanRigId) return; + const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0; + const modeEl = document.getElementById("mode"); + const mode = modeEl ? modeEl.value || "USB" : "USB"; + try { + const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, { + method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode }) + body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }) + }); + if (!resp.ok) { + const msg = await resp.text().catch(() => String(resp.status)); + console.warn("vchan: allocate failed —", msg); + return; } - ); - if (!resp.ok) console.warn("vchan: set mode failed", resp.status); - } catch (e) { - console.error("vchan: set mode error", e); + const ch = await resp.json(); + vchanActiveId = ch.id; + vchanRender(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: allocate error", e); + } } -} -window.vchanInterceptMode = async function(mode) { - if (!vchanIsOnVirtual()) return false; - await vchanSetChannelMode(mode); - return true; -}; -window.vchanInterceptBandwidth = async function(bwHz) { - if (!vchanIsOnVirtual()) return false; - await vchanSetChannelBandwidth(bwHz); - return true; -}; -(function() { - const _orig = window.setRigFrequency; - window.setRigFrequency = function(freqHz) { - if (vchanIsOnVirtual()) { - if (typeof applyLocalTunedFrequency === "function") { - if (typeof _freqOptimisticSeq !== "undefined") { - ++_freqOptimisticSeq; - _freqOptimisticHz = Math.round(freqHz); + async function vchanDelete(channelId) { + if (!vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`, + { method: "DELETE" } + ); + if (!resp.ok) { + console.warn("vchan: delete failed", resp.status); + } + } catch (e) { + console.error("vchan: delete error", e); + } + } + async function vchanAutoJoinPrimary(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) } - applyLocalTunedFrequency(Math.round(freqHz)); + ); + if (!resp.ok) { + console.warn("vchan: auto-join primary failed", resp.status); + return; } - vchanSetChannelFreq(freqHz); - return; + vchanActiveId = channelId; + vchanRender(); + } catch (e) { + console.error("vchan: auto-join error", e); } - vchanTakeSchedulerControl(); - if (typeof _orig === "function") _orig(freqHz); - }; -})(); -(function initSchedulerReleaseControl() { - const btn = document.getElementById("scheduler-release-btn"); - if (btn) { - btn.addEventListener("click", () => { - vchanToggleSchedulerRelease(); + } + async function vchanSubscribe(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) + } + ); + if (!resp.ok) { + console.warn("vchan: subscribe failed", resp.status); + return; + } + vchanActiveId = channelId; + vchanRender(); + vchanSyncModeDisplay(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: subscribe error", e); + } + } + function vchanReconnectAudio() { + const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; + vchanWindow._audioChannelOverride = ch?.id ?? null; + if (!vchanWindow.rxActive) return; + vchanWindow.stopRxAudio?.(); + setTimeout(() => { + vchanWindow.startRxAudio?.(); + }, 300); + } + function vchanApplyCapabilities(caps) { + const picker = document.getElementById("vchan-picker"); + if (!picker) return; + picker.style.display = caps && caps.filter_controls ? "" : "none"; + vchanRenderSchedulerRelease(); + } + vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities; + function vchanIsOnVirtual() { + if (!vchanActiveId || vchanChannels.length === 0) return false; + return vchanActiveId !== vchanChannels[0]?.id; + } + vchanWindow.vchanIsOnVirtual = vchanIsOnVirtual; + function vchanActiveChannel() { + return vchanChannels.find((c) => c.id === vchanActiveId) || null; + } + function vchanUpdateFreqDisplay() { + const ch = vchanActiveChannel(); + if (!ch) return; + const el = document.getElementById("freq"); + if (!el) return; + if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { + el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit); + } else { + el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); + } + } + function vchanSyncModeDisplay() { + const modeEl = document.getElementById("mode"); + if (!modeEl) return; + if (vchanIsOnVirtual()) { + const ch = vchanActiveChannel(); + if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); + } + const modeUpper = (modeEl.value || "").toUpperCase(); + if (typeof vchanWindow.lastModeName === "string") { + if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { + vchanWindow.setJogDivisor?.(10); + vchanWindow.resetRdsDisplay?.(); + } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { + vchanWindow.resetRdsDisplay?.(); + } + vchanWindow.lastModeName = modeUpper; + } + vchanWindow.updateWfmControls?.(); + vchanWindow.updateSdrSquelchControlVisibility?.(); + if (vchanWindow.refreshRdsUi) { + vchanWindow.refreshRdsUi(); + } else { + vchanWindow.positionRdsPsOverlay?.(); + } + } + function vchanSyncBwDisplay() { + if (!vchanIsOnVirtual()) return; + const ch = vchanActiveChannel(); + if (!ch) return; + const bwEl = document.getElementById("spectrum-bw-input"); + if (!bwEl) return; + let bwHz = ch.bandwidth_hz || 0; + if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { + bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; + } + if (bwHz > 0) { + bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); + vchanWindow.currentBandwidthHz = bwHz; + } + } + function vchanSyncAccentUI() { + const onVirtual = vchanIsOnVirtual(); + const freqEl = document.getElementById("freq"); + const bwEl = document.getElementById("spectrum-bw-input"); + if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual); + if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual); + if (onVirtual) { + vchanUpdateFreqDisplay(); + vchanSyncModeDisplay(); + vchanSyncBwDisplay(); + } else { + origRefreshFreqDisplay?.(); + } + if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { + vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); + } + } + var origRefreshFreqDisplay = null; + function vchanSetChannelFreq(freqHz) { + if (!vchanRigId || !vchanActiveId) return; + if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { + const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; + const center = vchanWindow.lastSpectrumData.center_hz; + if (Math.abs(freqHz - center) > halfSpan) { + if (vchanWindow.showHint) { + vchanWindow.showHint( + `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, + 3e3 + ); + } + return; + } + } + void vchanTakeSchedulerControl(); + void fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ freq_hz: Math.round(freqHz) }) + } + ).catch((error) => { + console.error("vchan: set freq error", error); }); } - vchanStartSchedulerReleasePolling(); - vchanRenderSchedulerRelease(); -})(); -(function() { - _origRefreshFreqDisplay = window.refreshFreqDisplay; - window.refreshFreqDisplay = function() { - if (vchanIsOnVirtual()) { - vchanUpdateFreqDisplay(); - return; + async function vchanSetChannelBandwidth(bwHz) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }) + } + ); + if (!resp.ok) console.warn("vchan: set bw failed", resp.status); + } catch (e) { + console.error("vchan: set bw error", e); } - if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay(); + } + async function vchanSetChannelMode(mode) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }) + } + ); + if (!resp.ok) console.warn("vchan: set mode failed", resp.status); + } catch (e) { + console.error("vchan: set mode error", e); + } + } + vchanWindow.vchanInterceptMode = async function(mode) { + if (!vchanIsOnVirtual()) return false; + await vchanSetChannelMode(mode); + return true; }; + vchanWindow.vchanInterceptBandwidth = async function(bwHz) { + if (!vchanIsOnVirtual()) return false; + await vchanSetChannelBandwidth(bwHz); + return true; + }; + (function() { + const original = vchanWindow.setRigFrequency; + vchanWindow.setRigFrequency = function(freqHz) { + if (vchanIsOnVirtual()) { + if (vchanWindow.applyLocalTunedFrequency) { + if (typeof vchanWindow._freqOptimisticSeq === "number") { + vchanWindow._freqOptimisticSeq += 1; + vchanWindow._freqOptimisticHz = Math.round(freqHz); + } + vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); + } + vchanSetChannelFreq(freqHz); + return; + } + void vchanTakeSchedulerControl(); + original?.(freqHz); + }; + })(); + (function initSchedulerReleaseControl() { + const btn = document.getElementById("scheduler-release-btn"); + if (btn) { + btn.addEventListener("click", () => { + void vchanToggleSchedulerRelease(); + }); + } + vchanStartSchedulerReleasePolling(); + vchanRenderSchedulerRelease(); + })(); + (function() { + origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null; + vchanWindow.refreshFreqDisplay = function() { + if (vchanIsOnVirtual()) { + vchanUpdateFreqDisplay(); + return; + } + origRefreshFreqDisplay?.(); + }; + })(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 83f0d9ed..a0818503 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -24,7 +24,6 @@ await build({ "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), scheduler: path.join(sourceDir, "plugins", "scheduler.js"), - vchan: path.join(sourceDir, "plugins", "vchan.js"), }, outdir: outputDir, bundle: false, @@ -51,6 +50,7 @@ await build({ "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"), sat: path.join(sourceDir, "plugins", "sat.ts"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), + vchan: path.join(sourceDir, "plugins", "vchan.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index 0de2e53d..6a45ecc5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -16,7 +16,7 @@ const pluginGroups: Readonly> = { const loaded = new Set(); const loading = new Map>(); -const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"]); +const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js"]); function loadLegacyScript(path: string): Promise { return new Promise((resolve, reject) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts similarity index 66% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts index d1509172..e6369d9b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.ts @@ -2,27 +2,85 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +export {}; + +interface VirtualChannel { + id: string; + index: number; + freq_hz: number; + mode: string; + bandwidth_hz?: number; + subscribers: number; + permanent: boolean; +} +interface ChannelsEvent { remote?: string | null; channels?: VirtualChannel[] } +interface SessionEvent { session_id?: string | null } +interface SchedulerReleaseState { + connected_sessions?: number; + released_sessions?: number; + all_released?: boolean; + current_session_released?: boolean; +} +interface SpectrumSnapshot { sample_rate: number; center_hz: number } +interface VirtualChannelBridge { + lastActiveRigId?: string | null; + lastFreqHz?: number; + lastModeName?: string; + lastSpectrumData?: SpectrumSnapshot | null; + currentBandwidthHz?: number; + jogUnit?: number; + rxActive?: boolean; + _audioChannelOverride?: string | null; + _freqOptimisticSeq?: number; + _freqOptimisticHz?: number; + renderRdsOverlays?: () => void; + updateDocumentTitle?: (rds: unknown) => void; + activeChannelRds?: () => unknown; + stopRxAudio?: () => void; + startRxAudio?: () => void; + formatFreqForStep?: (frequencyHz: number, jogUnit: number) => string; + setJogDivisor?: (divisor: number) => void; + resetRdsDisplay?: () => void; + updateWfmControls?: () => void; + updateSdrSquelchControlVisibility?: () => void; + refreshRdsUi?: () => void; + positionRdsPsOverlay?: () => void; + mwDefaultsForMode?: (mode: string) => [number, ...unknown[]]; + showHint?: (message: string, durationMs: number) => void; + applyLocalTunedFrequency?: (frequencyHz: number) => void; + setRigFrequency?: (frequencyHz: number) => void; + refreshFreqDisplay?: () => void; + vchanTakeSchedulerControl?: () => Promise; + vchanInterceptMode?: (mode: string) => Promise; + vchanInterceptBandwidth?: (bandwidthHz: number) => Promise; + vchanHandleSession?: (data: string) => void; + vchanHandleChannels?: (data: string) => void; + vchanApplyCapabilities?: (capabilities: { filter_controls?: boolean } | null) => void; + vchanIsOnVirtual?: () => boolean; +} +const vchanWindow = window as unknown as VirtualChannelBridge; + // --- Virtual Channels Plugin --- // // Handles the `session` and `channels` SSE events emitted by /events and // provides the channel picker UI (SDR-only, shown when filter_controls is set). -let vchanSessionId = null; -let vchanRigId = null; -let vchanChannels = []; -let vchanActiveId = null; -let schedulerReleaseState = null; -let schedulerReleasePollTimer = null; +let vchanSessionId: string | null = null; +let vchanRigId: string | null = null; +let vchanChannels: VirtualChannel[] = []; +let vchanActiveId: string | null = null; +let schedulerReleaseState: SchedulerReleaseState | null = null; +let schedulerReleasePollTimer: ReturnType | null = null; -function vchanFmtFreq(hz) { +function vchanFmtFreq(hz: number): string { if (!Number.isFinite(hz) || hz <= 0) return "--"; if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz"; - return hz + "\u202fHz"; + return `${String(hz)}\u202fHz`; } -function schedulerReleaseSummaryText(state) { +function schedulerReleaseSummaryText(state: SchedulerReleaseState | null): string { if (!state) return "Scheduler is controlling the rig."; const connected = Number(state.connected_sessions) || 0; const released = Number(state.released_sessions) || 0; @@ -45,7 +103,7 @@ function schedulerReleaseSummaryText(state) { } function vchanRenderSchedulerRelease() { - const btn = document.getElementById("scheduler-release-btn"); + const btn = document.getElementById("scheduler-release-btn") as HTMLButtonElement | null; const status = document.getElementById("scheduler-release-status"); if (!btn || !status) return; const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); @@ -55,7 +113,7 @@ function vchanRenderSchedulerRelease() { status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); } -async function vchanPollSchedulerRelease() { +async function vchanPollSchedulerRelease(): Promise { if (!vchanSessionId) { schedulerReleaseState = null; vchanRenderSchedulerRelease(); @@ -64,7 +122,7 @@ async function vchanPollSchedulerRelease() { try { const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - schedulerReleaseState = await resp.json(); + schedulerReleaseState = await resp.json() as SchedulerReleaseState; vchanRenderSchedulerRelease(); } catch (e) { console.error("scheduler release status failed", e); @@ -75,12 +133,12 @@ function vchanStartSchedulerReleasePolling() { if (schedulerReleasePollTimer) { clearInterval(schedulerReleasePollTimer); } - schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000); + schedulerReleasePollTimer = setInterval(() => { void vchanPollSchedulerRelease(); }, 10000); } async function vchanToggleSchedulerRelease() { if (!vchanSessionId) return; - const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null); + const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; try { const resp = await fetch("/scheduler-control", { method: "PUT", @@ -88,7 +146,7 @@ async function vchanToggleSchedulerRelease() { body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - schedulerReleaseState = await resp.json(); + schedulerReleaseState = await resp.json() as SchedulerReleaseState; vchanRenderSchedulerRelease(); } catch (e) { console.error("scheduler release toggle failed", e); @@ -105,50 +163,53 @@ async function vchanTakeSchedulerControl() { body: JSON.stringify({ session_id: vchanSessionId, released: false }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - schedulerReleaseState = await resp.json(); + schedulerReleaseState = await resp.json() as SchedulerReleaseState; vchanRenderSchedulerRelease(); } catch (e) { console.error("scheduler control takeover failed", e); } } -window.vchanTakeSchedulerControl = vchanTakeSchedulerControl; +vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl; // Called by app.js when the SSE `session` event arrives. -function vchanHandleSession(data) { +function vchanHandleSession(data: string): void { try { - const d = JSON.parse(data); + const d = JSON.parse(data) as SessionEvent; vchanSessionId = d.session_id || null; - vchanPollSchedulerRelease(); + void vchanPollSchedulerRelease(); } catch (e) { console.warn("vchan: bad session event", e); } } // Called by app.js when the SSE `channels` event arrives. -function vchanHandleChannels(data) { +function vchanHandleChannels(data: string): void { try { - const d = JSON.parse(data); + const d = JSON.parse(data) as ChannelsEvent; vchanRigId = d.remote || null; vchanChannels = d.channels || []; const ids = new Set(vchanChannels.map(c => c.id)); - if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) { + const primaryChannel = vchanChannels[0]; + if (!vchanActiveId && primaryChannel && vchanSessionId) { // First channels event for this session — auto-subscribe to channel 0 // so we join the same tuned channel as other users on this rig. // Use a direct subscribe (no scheduler control takeover) to avoid // side-effects on initial connect. - vchanAutoJoinPrimary(vchanChannels[0].id); + void vchanAutoJoinPrimary(primaryChannel.id); } else if (vchanActiveId && !ids.has(vchanActiveId)) { // Active channel was evicted — fall back to channel 0 and reconnect audio. - vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null; + vchanActiveId = vchanChannels[0]?.id ?? null; vchanReconnectAudio(); } vchanRender(); vchanRenderSchedulerRelease(); - if (typeof renderRdsOverlays === "function") renderRdsOverlays(); + vchanWindow.renderRdsOverlays?.(); } catch (e) { console.warn("vchan: bad channels event", e); } } +vchanWindow.vchanHandleSession = vchanHandleSession; +vchanWindow.vchanHandleChannels = vchanHandleChannels; function vchanRender() { const picker = document.getElementById("vchan-picker"); @@ -173,13 +234,13 @@ function vchanRender() { del.title = "Delete channel"; del.addEventListener("click", e => { e.stopPropagation(); - vchanDelete(ch.id); + void vchanDelete(ch.id); }); btn.appendChild(del); } btn.addEventListener("click", () => { - if (ch.id !== vchanActiveId) vchanSubscribe(ch.id); + if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id); }); picker.appendChild(btn); @@ -191,12 +252,12 @@ function vchanRender() { addBtn.className = "vchan-add"; addBtn.textContent = "+"; addBtn.title = "Allocate new virtual channel at current frequency"; - addBtn.addEventListener("click", vchanAllocate); + addBtn.addEventListener("click", () => { void vchanAllocate(); }); picker.appendChild(addBtn); vchanSyncAccentUI(); - if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { - updateDocumentTitle(activeChannelRds()); + if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { + vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); } vchanRenderSchedulerRelease(); } @@ -205,10 +266,10 @@ async function vchanAllocate() { if (!vchanSessionId || !vchanRigId) return; // Use the last known rig frequency and mode as the starting point. - const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0) - ? lastFreqHz + const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0) + ? vchanWindow.lastFreqHz : 0; - const modeEl = document.getElementById("mode"); + const modeEl = document.getElementById("mode") as HTMLSelectElement | null; const mode = modeEl ? (modeEl.value || "USB") : "USB"; try { @@ -222,7 +283,7 @@ async function vchanAllocate() { console.warn("vchan: allocate failed —", msg); return; } - const ch = await resp.json(); + const ch = await resp.json() as VirtualChannel; vchanActiveId = ch.id; // The SSE `channels` event will trigger vchanRender(); optimistically // mark active so the picker feels responsive even before the event arrives. @@ -233,7 +294,7 @@ async function vchanAllocate() { } } -async function vchanDelete(channelId) { +async function vchanDelete(channelId: string): Promise { if (!vchanRigId) return; try { const resp = await fetch( @@ -252,7 +313,7 @@ async function vchanDelete(channelId) { // Lightweight auto-join for initial connect: registers the session on // channel 0 without taking scheduler control or reconnecting audio // (audio isn't started yet at this point). -async function vchanAutoJoinPrimary(channelId) { +async function vchanAutoJoinPrimary(channelId: string): Promise { if (!vchanSessionId || !vchanRigId) return; try { const resp = await fetch( @@ -274,7 +335,7 @@ async function vchanAutoJoinPrimary(channelId) { } } -async function vchanSubscribe(channelId) { +async function vchanSubscribe(channelId: string): Promise { if (!vchanSessionId || !vchanRigId) return; try { await vchanTakeSchedulerControl(); @@ -304,43 +365,43 @@ async function vchanSubscribe(channelId) { // - primary channel: /audio (no param) // Always updates _audioChannelOverride so that starting audio later // connects to the correct channel. Only reconnects if RX audio is active. -function vchanReconnectAudio() { +function vchanReconnectAudio(): void { // Always update the override so startRxAudio picks up the right URL, // even when audio isn't currently running. const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; - if (typeof _audioChannelOverride !== "undefined") { - _audioChannelOverride = ch ? ch.id : null; - } - if (typeof rxActive === "undefined" || !rxActive) return; - if (typeof stopRxAudio === "function") stopRxAudio(); + vchanWindow._audioChannelOverride = ch?.id ?? null; + if (!vchanWindow.rxActive) return; + vchanWindow.stopRxAudio?.(); // Delay so the server has time to set up the per-channel encoder. // The server-side audio_ws handler also polls for up to 2 s, so this // just needs to be long enough for the WS upgrade to reach the server. setTimeout(() => { - if (typeof startRxAudio === "function") startRxAudio(); + vchanWindow.startRxAudio?.(); }, 300); } // Called by app.js from applyCapabilities(). // Shows the channel picker only for SDR rigs. -function vchanApplyCapabilities(caps) { +function vchanApplyCapabilities(caps: { filter_controls?: boolean } | null): void { const picker = document.getElementById("vchan-picker"); if (!picker) return; picker.style.display = (caps && caps.filter_controls) ? "" : "none"; vchanRenderSchedulerRelease(); } +vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities; // --------------------------------------------------------------------------- // Freq / mode interception + UI accent // --------------------------------------------------------------------------- // Returns true when the active channel is a non-primary (virtual) channel. -function vchanIsOnVirtual() { +function vchanIsOnVirtual(): boolean { if (!vchanActiveId || vchanChannels.length === 0) return false; - return vchanActiveId !== vchanChannels[0].id; + return vchanActiveId !== vchanChannels[0]?.id; } +vchanWindow.vchanIsOnVirtual = vchanIsOnVirtual; -function vchanActiveChannel() { +function vchanActiveChannel(): VirtualChannel | null { return vchanChannels.find(c => c.id === vchanActiveId) || null; } @@ -348,10 +409,10 @@ function vchanActiveChannel() { function vchanUpdateFreqDisplay() { const ch = vchanActiveChannel(); if (!ch) return; - const el = document.getElementById("freq"); + const el = document.getElementById("freq") as HTMLInputElement | null; if (!el) return; - if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") { - el.value = formatFreqForStep(ch.freq_hz, jogUnit); + if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { + el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit); } else { el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); } @@ -360,7 +421,7 @@ function vchanUpdateFreqDisplay() { // Sync the mode picker to the active virtual channel's mode. // Called whenever the active channel changes or the channel list is refreshed. function vchanSyncModeDisplay() { - const modeEl = document.getElementById("mode"); + const modeEl = document.getElementById("mode") as HTMLSelectElement | null; if (!modeEl) return; if (vchanIsOnVirtual()) { const ch = vchanActiveChannel(); @@ -368,23 +429,21 @@ function vchanSyncModeDisplay() { } // When on primary channel, app.js rig-state updates handle the picker. const modeUpper = (modeEl.value || "").toUpperCase(); - if (typeof lastModeName !== "undefined") { - if (modeUpper === "WFM" && lastModeName !== "WFM") { - if (typeof setJogDivisor === "function") setJogDivisor(10); - if (typeof resetRdsDisplay === "function") resetRdsDisplay(); - } else if (modeUpper !== "WFM" && lastModeName === "WFM") { - if (typeof resetRdsDisplay === "function") resetRdsDisplay(); + if (typeof vchanWindow.lastModeName === "string") { + if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { + vchanWindow.setJogDivisor?.(10); + vchanWindow.resetRdsDisplay?.(); + } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { + vchanWindow.resetRdsDisplay?.(); } - lastModeName = modeUpper; + vchanWindow.lastModeName = modeUpper; } - if (typeof updateWfmControls === "function") updateWfmControls(); - if (typeof updateSdrSquelchControlVisibility === "function") { - updateSdrSquelchControlVisibility(); - } - if (typeof refreshRdsUi === "function") { - refreshRdsUi(); - } else if (typeof positionRdsPsOverlay === "function") { - positionRdsPsOverlay(); + vchanWindow.updateWfmControls?.(); + vchanWindow.updateSdrSquelchControlVisibility?.(); + if (vchanWindow.refreshRdsUi) { + vchanWindow.refreshRdsUi(); + } else { + vchanWindow.positionRdsPsOverlay?.(); } } @@ -393,21 +452,16 @@ function vchanSyncBwDisplay() { if (!vchanIsOnVirtual()) return; const ch = vchanActiveChannel(); if (!ch) return; - const bwEl = document.getElementById("spectrum-bw-input"); + const bwEl = document.getElementById("spectrum-bw-input") as HTMLInputElement | null; if (!bwEl) return; // bandwidth_hz == 0 means mode-default; derive it from the channel mode. let bwHz = ch.bandwidth_hz || 0; - if (bwHz === 0 && typeof mwDefaultsForMode === "function") { - bwHz = mwDefaultsForMode(ch.mode)[0] || 0; + if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { + bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; } if (bwHz > 0) { bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, ""); - if (typeof currentBandwidthHz !== "undefined") { - currentBandwidthHz = bwHz; - window.currentBandwidthHz = bwHz; - } else { - window.currentBandwidthHz = bwHz; - } + vchanWindow.currentBandwidthHz = bwHz; } } @@ -422,27 +476,26 @@ function vchanSyncAccentUI() { vchanUpdateFreqDisplay(); vchanSyncModeDisplay(); vchanSyncBwDisplay(); - } else if (typeof _origRefreshFreqDisplay === "function") { - _origRefreshFreqDisplay(); + } else { + origRefreshFreqDisplay?.(); } - if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { - updateDocumentTitle(activeChannelRds()); + if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { + vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); } } // Saved reference to the original refreshFreqDisplay from app.js. -let _origRefreshFreqDisplay = null; +let origRefreshFreqDisplay: (() => void) | null = null; -function vchanSetChannelFreq(freqHz) { +function vchanSetChannelFreq(freqHz: number): void { if (!vchanRigId || !vchanActiveId) return; // Validate against current SDR capture window. - if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && - lastSpectrumData.sample_rate > 0) { - const halfSpan = Number(lastSpectrumData.sample_rate) / 2; - const center = Number(lastSpectrumData.center_hz); + if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { + const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; + const center = vchanWindow.lastSpectrumData.center_hz; if (Math.abs(freqHz - center) > halfSpan) { - if (typeof showHint === "function") { - showHint( + if (vchanWindow.showHint) { + vchanWindow.showHint( `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, 3000 ); @@ -451,18 +504,18 @@ function vchanSetChannelFreq(freqHz) { } } // Fire-and-forget: scheduler control + channel freq PUT run in background. - vchanTakeSchedulerControl(); - fetch( + void vchanTakeSchedulerControl(); + void fetch( `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ freq_hz: Math.round(freqHz) }), } - ).catch(e => console.error("vchan: set freq error", e)); + ).catch((error: unknown) => { console.error("vchan: set freq error", error); }); } -async function vchanSetChannelBandwidth(bwHz) { +async function vchanSetChannelBandwidth(bwHz: number): Promise { if (!vchanRigId || !vchanActiveId) return; try { await vchanTakeSchedulerControl(); @@ -480,7 +533,7 @@ async function vchanSetChannelBandwidth(bwHz) { } } -async function vchanSetChannelMode(mode) { +async function vchanSetChannelMode(mode: string): Promise { if (!vchanRigId || !vchanActiveId) return; try { await vchanTakeSchedulerControl(); @@ -501,7 +554,7 @@ async function vchanSetChannelMode(mode) { // Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before // sending /set_mode to the server. Returns true if the change was handled // by the virtual channel (caller should skip the server request). -window.vchanInterceptMode = async function(mode) { +vchanWindow.vchanInterceptMode = async function(mode: string) { if (!vchanIsOnVirtual()) return false; await vchanSetChannelMode(mode); return true; @@ -509,7 +562,7 @@ window.vchanInterceptMode = async function(mode) { // Called by app.js bandwidth setters before sending /set_bandwidth to the // server. Returns true if the change was handled by the virtual channel. -window.vchanInterceptBandwidth = async function(bwHz) { +vchanWindow.vchanInterceptBandwidth = async function(bwHz: number) { if (!vchanIsOnVirtual()) return false; await vchanSetChannelBandwidth(bwHz); return true; @@ -519,23 +572,23 @@ window.vchanInterceptBandwidth = async function(bwHz) { // frequency changes are redirected to the active virtual channel instead of // the server when on a non-primary channel. (function() { - const _orig = window.setRigFrequency; - window.setRigFrequency = function(freqHz) { + const original = vchanWindow.setRigFrequency; + vchanWindow.setRigFrequency = function(freqHz: number) { if (vchanIsOnVirtual()) { // Optimistic local update first, then fire-and-forget channel API. - if (typeof applyLocalTunedFrequency === "function") { - if (typeof _freqOptimisticSeq !== "undefined") { - ++_freqOptimisticSeq; - _freqOptimisticHz = Math.round(freqHz); + if (vchanWindow.applyLocalTunedFrequency) { + if (typeof vchanWindow._freqOptimisticSeq === "number") { + vchanWindow._freqOptimisticSeq += 1; + vchanWindow._freqOptimisticHz = Math.round(freqHz); } - applyLocalTunedFrequency(Math.round(freqHz)); + vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); } vchanSetChannelFreq(freqHz); return; } // Scheduler control is fire-and-forget — don't block the freq change. - vchanTakeSchedulerControl(); - if (typeof _orig === "function") _orig(freqHz); + void vchanTakeSchedulerControl(); + original?.(freqHz); }; })(); @@ -543,7 +596,7 @@ window.vchanInterceptBandwidth = async function(bwHz) { const btn = document.getElementById("scheduler-release-btn"); if (btn) { btn.addEventListener("click", () => { - vchanToggleSchedulerRelease(); + void vchanToggleSchedulerRelease(); }); } vchanStartSchedulerReleasePolling(); @@ -554,12 +607,12 @@ window.vchanInterceptBandwidth = async function(bwHz) { // active virtual channel's frequency (SSE rig-state updates would otherwise // constantly overwrite it with channel 0's freq). (function() { - _origRefreshFreqDisplay = window.refreshFreqDisplay; - window.refreshFreqDisplay = function() { + origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null; + vchanWindow.refreshFreqDisplay = function() { if (vchanIsOnVirtual()) { vchanUpdateFreqDisplay(); return; } - if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay(); + origRefreshFreqDisplay?.(); }; })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs new file mode 100644 index 00000000..a0f5d10c --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vchan.test.mjs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +test("virtual channels expose typed SSE and interception boundaries", async () => { + const window = {}; + const context = vm.createContext({ + window, + document: { getElementById: () => null }, + fetch: async () => ({ ok: true, json: async () => ({ connected_sessions: 1, released_sessions: 0 }) }), + setInterval: () => 1, + clearInterval() {}, + setTimeout, + Date, + Number, + String, + Array, + Set, + JSON, + Error, + console, + }); + const source = await readFile(new URL("../../assets/web/generated/vchan.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + assert.equal(typeof window.vchanHandleSession, "function"); + assert.equal(typeof window.vchanHandleChannels, "function"); + assert.equal(typeof window.vchanApplyCapabilities, "function"); + assert.equal(await window.vchanInterceptMode("USB"), false); + assert.equal(await window.vchanInterceptBandwidth(2400), false); +});