refactor: convert virtual channels to TypeScript

This commit is contained in:
sjg
2026-08-01 12:57:09 +02:00
parent fc2f55bab9
commit 6a83e2e90a
6 changed files with 639 additions and 544 deletions
@@ -10,7 +10,7 @@ const pluginGroups = {
}; };
const loaded = /* @__PURE__ */ new Set(); const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map(); 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) { function loadLegacyScript(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
@@ -1,18 +1,21 @@
"use strict"; "use strict";
let vchanSessionId = null; (() => {
let vchanRigId = null; // src/plugins/vchan.ts
let vchanChannels = []; var vchanWindow = window;
let vchanActiveId = null; var vchanSessionId = null;
let schedulerReleaseState = null; var vchanRigId = null;
let schedulerReleasePollTimer = null; var vchanChannels = [];
function vchanFmtFreq(hz) { var vchanActiveId = null;
var schedulerReleaseState = null;
var schedulerReleasePollTimer = null;
function vchanFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--"; if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "GHz"; if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "GHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "MHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "kHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "kHz";
return hz + "Hz"; return `${String(hz)}Hz`;
} }
function schedulerReleaseSummaryText(state) { function schedulerReleaseSummaryText(state) {
if (!state) return "Scheduler is controlling the rig."; if (!state) return "Scheduler is controlling the rig.";
const connected = Number(state.connected_sessions) || 0; const connected = Number(state.connected_sessions) || 0;
const released = Number(state.released_sessions) || 0; const released = Number(state.released_sessions) || 0;
@@ -26,8 +29,8 @@ function schedulerReleaseSummaryText(state) {
} }
const blocking = Math.max(connected - released, 0); 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."; return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
} }
function vchanRenderSchedulerRelease() { function vchanRenderSchedulerRelease() {
const btn = document.getElementById("scheduler-release-btn"); const btn = document.getElementById("scheduler-release-btn");
const status = document.getElementById("scheduler-release-status"); const status = document.getElementById("scheduler-release-status");
if (!btn || !status) return; if (!btn || !status) return;
@@ -36,8 +39,8 @@ function vchanRenderSchedulerRelease() {
btn.classList.toggle("active", !currentReleased); btn.classList.toggle("active", !currentReleased);
btn.textContent = "Release to Scheduler"; btn.textContent = "Release to Scheduler";
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
} }
async function vchanPollSchedulerRelease() { async function vchanPollSchedulerRelease() {
if (!vchanSessionId) { if (!vchanSessionId) {
schedulerReleaseState = null; schedulerReleaseState = null;
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
@@ -51,16 +54,18 @@ async function vchanPollSchedulerRelease() {
} catch (e) { } catch (e) {
console.error("scheduler release status failed", e); console.error("scheduler release status failed", e);
} }
} }
function vchanStartSchedulerReleasePolling() { function vchanStartSchedulerReleasePolling() {
if (schedulerReleasePollTimer) { if (schedulerReleasePollTimer) {
clearInterval(schedulerReleasePollTimer); clearInterval(schedulerReleasePollTimer);
} }
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 1e4); schedulerReleasePollTimer = setInterval(() => {
} void vchanPollSchedulerRelease();
async function vchanToggleSchedulerRelease() { }, 1e4);
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return; if (!vchanSessionId) return;
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null); const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
try { try {
const resp = await fetch("/scheduler-control", { const resp = await fetch("/scheduler-control", {
method: "PUT", method: "PUT",
@@ -73,8 +78,8 @@ async function vchanToggleSchedulerRelease() {
} catch (e) { } catch (e) {
console.error("scheduler release toggle failed", e); console.error("scheduler release toggle failed", e);
} }
} }
async function vchanTakeSchedulerControl() { async function vchanTakeSchedulerControl() {
if (!vchanSessionId) return; if (!vchanSessionId) return;
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return; if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
try { try {
@@ -89,37 +94,40 @@ async function vchanTakeSchedulerControl() {
} catch (e) { } catch (e) {
console.error("scheduler control takeover failed", e); console.error("scheduler control takeover failed", e);
} }
} }
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl; vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
function vchanHandleSession(data) { function vchanHandleSession(data) {
try { try {
const d = JSON.parse(data); const d = JSON.parse(data);
vchanSessionId = d.session_id || null; vchanSessionId = d.session_id || null;
vchanPollSchedulerRelease(); void vchanPollSchedulerRelease();
} catch (e) { } catch (e) {
console.warn("vchan: bad session event", e); console.warn("vchan: bad session event", e);
} }
} }
function vchanHandleChannels(data) { function vchanHandleChannels(data) {
try { try {
const d = JSON.parse(data); const d = JSON.parse(data);
vchanRigId = d.remote || null; vchanRigId = d.remote || null;
vchanChannels = d.channels || []; vchanChannels = d.channels || [];
const ids = new Set(vchanChannels.map((c) => c.id)); const ids = new Set(vchanChannels.map((c) => c.id));
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) { const primaryChannel = vchanChannels[0];
vchanAutoJoinPrimary(vchanChannels[0].id); if (!vchanActiveId && primaryChannel && vchanSessionId) {
void vchanAutoJoinPrimary(primaryChannel.id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) { } else if (vchanActiveId && !ids.has(vchanActiveId)) {
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null; vchanActiveId = vchanChannels[0]?.id ?? null;
vchanReconnectAudio(); vchanReconnectAudio();
} }
vchanRender(); vchanRender();
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
if (typeof renderRdsOverlays === "function") renderRdsOverlays(); vchanWindow.renderRdsOverlays?.();
} catch (e) { } catch (e) {
console.warn("vchan: bad channels event", e); console.warn("vchan: bad channels event", e);
} }
} }
function vchanRender() { vchanWindow.vchanHandleSession = vchanHandleSession;
vchanWindow.vchanHandleChannels = vchanHandleChannels;
function vchanRender() {
const picker = document.getElementById("vchan-picker"); const picker = document.getElementById("vchan-picker");
if (!picker) return; if (!picker) return;
picker.innerHTML = ""; picker.innerHTML = "";
@@ -139,12 +147,12 @@ function vchanRender() {
del.title = "Delete channel"; del.title = "Delete channel";
del.addEventListener("click", (e) => { del.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
vchanDelete(ch.id); void vchanDelete(ch.id);
}); });
btn.appendChild(del); btn.appendChild(del);
} }
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id); if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
}); });
picker.appendChild(btn); picker.appendChild(btn);
}); });
@@ -153,17 +161,19 @@ function vchanRender() {
addBtn.className = "vchan-add"; addBtn.className = "vchan-add";
addBtn.textContent = "+"; addBtn.textContent = "+";
addBtn.title = "Allocate new virtual channel at current frequency"; addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", vchanAllocate); addBtn.addEventListener("click", () => {
void vchanAllocate();
});
picker.appendChild(addBtn); picker.appendChild(addBtn);
vchanSyncAccentUI(); vchanSyncAccentUI();
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
updateDocumentTitle(activeChannelRds()); vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
} }
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} }
async function vchanAllocate() { async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
const freqHz = typeof lastFreqHz === "number" && lastFreqHz > 0 ? lastFreqHz : 0; const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0;
const modeEl = document.getElementById("mode"); const modeEl = document.getElementById("mode");
const mode = modeEl ? modeEl.value || "USB" : "USB"; const mode = modeEl ? modeEl.value || "USB" : "USB";
try { try {
@@ -184,8 +194,8 @@ async function vchanAllocate() {
} catch (e) { } catch (e) {
console.error("vchan: allocate error", e); console.error("vchan: allocate error", e);
} }
} }
async function vchanDelete(channelId) { async function vchanDelete(channelId) {
if (!vchanRigId) return; if (!vchanRigId) return;
try { try {
const resp = await fetch( const resp = await fetch(
@@ -198,8 +208,8 @@ async function vchanDelete(channelId) {
} catch (e) { } catch (e) {
console.error("vchan: delete error", e); console.error("vchan: delete error", e);
} }
} }
async function vchanAutoJoinPrimary(channelId) { async function vchanAutoJoinPrimary(channelId) {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
try { try {
const resp = await fetch( const resp = await fetch(
@@ -219,8 +229,8 @@ async function vchanAutoJoinPrimary(channelId) {
} catch (e) { } catch (e) {
console.error("vchan: auto-join error", e); console.error("vchan: auto-join error", e);
} }
} }
async function vchanSubscribe(channelId) { async function vchanSubscribe(channelId) {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
try { try {
await vchanTakeSchedulerControl(); await vchanTakeSchedulerControl();
@@ -243,43 +253,43 @@ async function vchanSubscribe(channelId) {
} catch (e) { } catch (e) {
console.error("vchan: subscribe error", 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; function vchanReconnectAudio() {
if (typeof stopRxAudio === "function") stopRxAudio(); const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return;
vchanWindow.stopRxAudio?.();
setTimeout(() => { setTimeout(() => {
if (typeof startRxAudio === "function") startRxAudio(); vchanWindow.startRxAudio?.();
}, 300); }, 300);
} }
function vchanApplyCapabilities(caps) { function vchanApplyCapabilities(caps) {
const picker = document.getElementById("vchan-picker"); const picker = document.getElementById("vchan-picker");
if (!picker) return; if (!picker) return;
picker.style.display = caps && caps.filter_controls ? "" : "none"; picker.style.display = caps && caps.filter_controls ? "" : "none";
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} }
function vchanIsOnVirtual() { vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities;
function vchanIsOnVirtual() {
if (!vchanActiveId || vchanChannels.length === 0) return false; if (!vchanActiveId || vchanChannels.length === 0) return false;
return vchanActiveId !== vchanChannels[0].id; return vchanActiveId !== vchanChannels[0]?.id;
} }
function vchanActiveChannel() { vchanWindow.vchanIsOnVirtual = vchanIsOnVirtual;
function vchanActiveChannel() {
return vchanChannels.find((c) => c.id === vchanActiveId) || null; return vchanChannels.find((c) => c.id === vchanActiveId) || null;
} }
function vchanUpdateFreqDisplay() { function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel(); const ch = vchanActiveChannel();
if (!ch) return; if (!ch) return;
const el = document.getElementById("freq"); const el = document.getElementById("freq");
if (!el) return; if (!el) return;
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") { if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
el.value = formatFreqForStep(ch.freq_hz, jogUnit); el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else { } else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
} }
} }
function vchanSyncModeDisplay() { function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode"); const modeEl = document.getElementById("mode");
if (!modeEl) return; if (!modeEl) return;
if (vchanIsOnVirtual()) { if (vchanIsOnVirtual()) {
@@ -287,46 +297,39 @@ function vchanSyncModeDisplay() {
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
} }
const modeUpper = (modeEl.value || "").toUpperCase(); const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof lastModeName !== "undefined") { if (typeof vchanWindow.lastModeName === "string") {
if (modeUpper === "WFM" && lastModeName !== "WFM") { if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
if (typeof setJogDivisor === "function") setJogDivisor(10); vchanWindow.setJogDivisor?.(10);
if (typeof resetRdsDisplay === "function") resetRdsDisplay(); vchanWindow.resetRdsDisplay?.();
} else if (modeUpper !== "WFM" && lastModeName === "WFM") { } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
if (typeof resetRdsDisplay === "function") resetRdsDisplay(); vchanWindow.resetRdsDisplay?.();
} }
lastModeName = modeUpper; vchanWindow.lastModeName = modeUpper;
} }
if (typeof updateWfmControls === "function") updateWfmControls(); vchanWindow.updateWfmControls?.();
if (typeof updateSdrSquelchControlVisibility === "function") { vchanWindow.updateSdrSquelchControlVisibility?.();
updateSdrSquelchControlVisibility(); if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi();
} else {
vchanWindow.positionRdsPsOverlay?.();
} }
if (typeof refreshRdsUi === "function") {
refreshRdsUi();
} else if (typeof positionRdsPsOverlay === "function") {
positionRdsPsOverlay();
} }
} function vchanSyncBwDisplay() {
function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return; if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel(); const ch = vchanActiveChannel();
if (!ch) return; if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input"); const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return; if (!bwEl) return;
let bwHz = ch.bandwidth_hz || 0; let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && typeof mwDefaultsForMode === "function") { if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
bwHz = mwDefaultsForMode(ch.mode)[0] || 0; bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
} }
if (bwHz > 0) { if (bwHz > 0) {
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
if (typeof currentBandwidthHz !== "undefined") { vchanWindow.currentBandwidthHz = bwHz;
currentBandwidthHz = bwHz;
window.currentBandwidthHz = bwHz;
} else {
window.currentBandwidthHz = bwHz;
} }
} }
} function vchanSyncAccentUI() {
function vchanSyncAccentUI() {
const onVirtual = vchanIsOnVirtual(); const onVirtual = vchanIsOnVirtual();
const freqEl = document.getElementById("freq"); const freqEl = document.getElementById("freq");
const bwEl = document.getElementById("spectrum-bw-input"); const bwEl = document.getElementById("spectrum-bw-input");
@@ -336,22 +339,22 @@ function vchanSyncAccentUI() {
vchanUpdateFreqDisplay(); vchanUpdateFreqDisplay();
vchanSyncModeDisplay(); vchanSyncModeDisplay();
vchanSyncBwDisplay(); vchanSyncBwDisplay();
} else if (typeof _origRefreshFreqDisplay === "function") { } else {
_origRefreshFreqDisplay(); origRefreshFreqDisplay?.();
} }
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
updateDocumentTitle(activeChannelRds()); vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
} }
} }
let _origRefreshFreqDisplay = null; var origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) { function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && lastSpectrumData.sample_rate > 0) { if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
const halfSpan = Number(lastSpectrumData.sample_rate) / 2; const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
const center = Number(lastSpectrumData.center_hz); const center = vchanWindow.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) { if (Math.abs(freqHz - center) > halfSpan) {
if (typeof showHint === "function") { if (vchanWindow.showHint) {
showHint( vchanWindow.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3e3 3e3
); );
@@ -359,17 +362,19 @@ function vchanSetChannelFreq(freqHz) {
return; return;
} }
} }
vchanTakeSchedulerControl(); void vchanTakeSchedulerControl();
fetch( void fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) }) body: JSON.stringify({ freq_hz: Math.round(freqHz) })
} }
).catch((e) => console.error("vchan: set freq error", e)); ).catch((error) => {
} console.error("vchan: set freq error", error);
async function vchanSetChannelBandwidth(bwHz) { });
}
async function vchanSetChannelBandwidth(bwHz) {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
try { try {
await vchanTakeSchedulerControl(); await vchanTakeSchedulerControl();
@@ -385,8 +390,8 @@ async function vchanSetChannelBandwidth(bwHz) {
} catch (e) { } catch (e) {
console.error("vchan: set bw error", e); console.error("vchan: set bw error", e);
} }
} }
async function vchanSetChannelMode(mode) { async function vchanSetChannelMode(mode) {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
try { try {
await vchanTakeSchedulerControl(); await vchanTakeSchedulerControl();
@@ -402,52 +407,53 @@ async function vchanSetChannelMode(mode) {
} catch (e) { } catch (e) {
console.error("vchan: set mode error", e); console.error("vchan: set mode error", e);
} }
} }
window.vchanInterceptMode = async function(mode) { vchanWindow.vchanInterceptMode = async function(mode) {
if (!vchanIsOnVirtual()) return false; if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode); await vchanSetChannelMode(mode);
return true; return true;
}; };
window.vchanInterceptBandwidth = async function(bwHz) { vchanWindow.vchanInterceptBandwidth = async function(bwHz) {
if (!vchanIsOnVirtual()) return false; if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz); await vchanSetChannelBandwidth(bwHz);
return true; return true;
}; };
(function() { (function() {
const _orig = window.setRigFrequency; const original = vchanWindow.setRigFrequency;
window.setRigFrequency = function(freqHz) { vchanWindow.setRigFrequency = function(freqHz) {
if (vchanIsOnVirtual()) { if (vchanIsOnVirtual()) {
if (typeof applyLocalTunedFrequency === "function") { if (vchanWindow.applyLocalTunedFrequency) {
if (typeof _freqOptimisticSeq !== "undefined") { if (typeof vchanWindow._freqOptimisticSeq === "number") {
++_freqOptimisticSeq; vchanWindow._freqOptimisticSeq += 1;
_freqOptimisticHz = Math.round(freqHz); vchanWindow._freqOptimisticHz = Math.round(freqHz);
} }
applyLocalTunedFrequency(Math.round(freqHz)); vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
} }
vchanSetChannelFreq(freqHz); vchanSetChannelFreq(freqHz);
return; return;
} }
vchanTakeSchedulerControl(); void vchanTakeSchedulerControl();
if (typeof _orig === "function") _orig(freqHz); original?.(freqHz);
}; };
})(); })();
(function initSchedulerReleaseControl() { (function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn"); const btn = document.getElementById("scheduler-release-btn");
if (btn) { if (btn) {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
vchanToggleSchedulerRelease(); void vchanToggleSchedulerRelease();
}); });
} }
vchanStartSchedulerReleasePolling(); vchanStartSchedulerReleasePolling();
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
})(); })();
(function() { (function() {
_origRefreshFreqDisplay = window.refreshFreqDisplay; origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
window.refreshFreqDisplay = function() { vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) { if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay(); vchanUpdateFreqDisplay();
return; return;
} }
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay(); origRefreshFreqDisplay?.();
}; };
})();
})(); })();
@@ -24,7 +24,6 @@ await build({
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
scheduler: path.join(sourceDir, "plugins", "scheduler.js"), scheduler: path.join(sourceDir, "plugins", "scheduler.js"),
vchan: path.join(sourceDir, "plugins", "vchan.js"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: false, bundle: false,
@@ -51,6 +50,7 @@ await build({
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
sat: path.join(sourceDir, "plugins", "sat.ts"), sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: true, bundle: true,
@@ -16,7 +16,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
const loaded = new Set<string>(); const loaded = new Set<string>();
const loading = new Map<string, Promise<void>>(); const loading = new Map<string, Promise<void>>();
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<void> { function loadLegacyScript(path: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -2,27 +2,85 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // 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<void>;
vchanInterceptMode?: (mode: string) => Promise<boolean>;
vchanInterceptBandwidth?: (bandwidthHz: number) => Promise<boolean>;
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 --- // --- Virtual Channels Plugin ---
// //
// Handles the `session` and `channels` SSE events emitted by /events and // Handles the `session` and `channels` SSE events emitted by /events and
// provides the channel picker UI (SDR-only, shown when filter_controls is set). // provides the channel picker UI (SDR-only, shown when filter_controls is set).
let vchanSessionId = null; let vchanSessionId: string | null = null;
let vchanRigId = null; let vchanRigId: string | null = null;
let vchanChannels = []; let vchanChannels: VirtualChannel[] = [];
let vchanActiveId = null; let vchanActiveId: string | null = null;
let schedulerReleaseState = null; let schedulerReleaseState: SchedulerReleaseState | null = null;
let schedulerReleasePollTimer = null; let schedulerReleasePollTimer: ReturnType<typeof setInterval> | null = null;
function vchanFmtFreq(hz) { function vchanFmtFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "--"; if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz"; if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz"; 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."; if (!state) return "Scheduler is controlling the rig.";
const connected = Number(state.connected_sessions) || 0; const connected = Number(state.connected_sessions) || 0;
const released = Number(state.released_sessions) || 0; const released = Number(state.released_sessions) || 0;
@@ -45,7 +103,7 @@ function schedulerReleaseSummaryText(state) {
} }
function vchanRenderSchedulerRelease() { 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"); const status = document.getElementById("scheduler-release-status");
if (!btn || !status) return; if (!btn || !status) return;
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
@@ -55,7 +113,7 @@ function vchanRenderSchedulerRelease() {
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
} }
async function vchanPollSchedulerRelease() { async function vchanPollSchedulerRelease(): Promise<void> {
if (!vchanSessionId) { if (!vchanSessionId) {
schedulerReleaseState = null; schedulerReleaseState = null;
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
@@ -64,7 +122,7 @@ async function vchanPollSchedulerRelease() {
try { try {
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json(); schedulerReleaseState = await resp.json() as SchedulerReleaseState;
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} catch (e) { } catch (e) {
console.error("scheduler release status failed", e); console.error("scheduler release status failed", e);
@@ -75,12 +133,12 @@ function vchanStartSchedulerReleasePolling() {
if (schedulerReleasePollTimer) { if (schedulerReleasePollTimer) {
clearInterval(schedulerReleasePollTimer); clearInterval(schedulerReleasePollTimer);
} }
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000); schedulerReleasePollTimer = setInterval(() => { void vchanPollSchedulerRelease(); }, 10000);
} }
async function vchanToggleSchedulerRelease() { async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return; if (!vchanSessionId) return;
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null); const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
try { try {
const resp = await fetch("/scheduler-control", { const resp = await fetch("/scheduler-control", {
method: "PUT", method: "PUT",
@@ -88,7 +146,7 @@ async function vchanToggleSchedulerRelease() {
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }), body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
}); });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json(); schedulerReleaseState = await resp.json() as SchedulerReleaseState;
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} catch (e) { } catch (e) {
console.error("scheduler release toggle failed", e); console.error("scheduler release toggle failed", e);
@@ -105,50 +163,53 @@ async function vchanTakeSchedulerControl() {
body: JSON.stringify({ session_id: vchanSessionId, released: false }), body: JSON.stringify({ session_id: vchanSessionId, released: false }),
}); });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json(); schedulerReleaseState = await resp.json() as SchedulerReleaseState;
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} catch (e) { } catch (e) {
console.error("scheduler control takeover failed", e); console.error("scheduler control takeover failed", e);
} }
} }
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl; vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
// Called by app.js when the SSE `session` event arrives. // Called by app.js when the SSE `session` event arrives.
function vchanHandleSession(data) { function vchanHandleSession(data: string): void {
try { try {
const d = JSON.parse(data); const d = JSON.parse(data) as SessionEvent;
vchanSessionId = d.session_id || null; vchanSessionId = d.session_id || null;
vchanPollSchedulerRelease(); void vchanPollSchedulerRelease();
} catch (e) { } catch (e) {
console.warn("vchan: bad session event", e); console.warn("vchan: bad session event", e);
} }
} }
// Called by app.js when the SSE `channels` event arrives. // Called by app.js when the SSE `channels` event arrives.
function vchanHandleChannels(data) { function vchanHandleChannels(data: string): void {
try { try {
const d = JSON.parse(data); const d = JSON.parse(data) as ChannelsEvent;
vchanRigId = d.remote || null; vchanRigId = d.remote || null;
vchanChannels = d.channels || []; vchanChannels = d.channels || [];
const ids = new Set(vchanChannels.map(c => c.id)); 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 // First channels event for this session — auto-subscribe to channel 0
// so we join the same tuned channel as other users on this rig. // so we join the same tuned channel as other users on this rig.
// Use a direct subscribe (no scheduler control takeover) to avoid // Use a direct subscribe (no scheduler control takeover) to avoid
// side-effects on initial connect. // side-effects on initial connect.
vchanAutoJoinPrimary(vchanChannels[0].id); void vchanAutoJoinPrimary(primaryChannel.id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) { } else if (vchanActiveId && !ids.has(vchanActiveId)) {
// Active channel was evicted — fall back to channel 0 and reconnect audio. // 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(); vchanReconnectAudio();
} }
vchanRender(); vchanRender();
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
if (typeof renderRdsOverlays === "function") renderRdsOverlays(); vchanWindow.renderRdsOverlays?.();
} catch (e) { } catch (e) {
console.warn("vchan: bad channels event", e); console.warn("vchan: bad channels event", e);
} }
} }
vchanWindow.vchanHandleSession = vchanHandleSession;
vchanWindow.vchanHandleChannels = vchanHandleChannels;
function vchanRender() { function vchanRender() {
const picker = document.getElementById("vchan-picker"); const picker = document.getElementById("vchan-picker");
@@ -173,13 +234,13 @@ function vchanRender() {
del.title = "Delete channel"; del.title = "Delete channel";
del.addEventListener("click", e => { del.addEventListener("click", e => {
e.stopPropagation(); e.stopPropagation();
vchanDelete(ch.id); void vchanDelete(ch.id);
}); });
btn.appendChild(del); btn.appendChild(del);
} }
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id); if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
}); });
picker.appendChild(btn); picker.appendChild(btn);
@@ -191,12 +252,12 @@ function vchanRender() {
addBtn.className = "vchan-add"; addBtn.className = "vchan-add";
addBtn.textContent = "+"; addBtn.textContent = "+";
addBtn.title = "Allocate new virtual channel at current frequency"; addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", vchanAllocate); addBtn.addEventListener("click", () => { void vchanAllocate(); });
picker.appendChild(addBtn); picker.appendChild(addBtn);
vchanSyncAccentUI(); vchanSyncAccentUI();
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
updateDocumentTitle(activeChannelRds()); vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
} }
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} }
@@ -205,10 +266,10 @@ async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
// Use the last known rig frequency and mode as the starting point. // Use the last known rig frequency and mode as the starting point.
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0) const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0)
? lastFreqHz ? vchanWindow.lastFreqHz
: 0; : 0;
const modeEl = document.getElementById("mode"); const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
const mode = modeEl ? (modeEl.value || "USB") : "USB"; const mode = modeEl ? (modeEl.value || "USB") : "USB";
try { try {
@@ -222,7 +283,7 @@ async function vchanAllocate() {
console.warn("vchan: allocate failed —", msg); console.warn("vchan: allocate failed —", msg);
return; return;
} }
const ch = await resp.json(); const ch = await resp.json() as VirtualChannel;
vchanActiveId = ch.id; vchanActiveId = ch.id;
// The SSE `channels` event will trigger vchanRender(); optimistically // The SSE `channels` event will trigger vchanRender(); optimistically
// mark active so the picker feels responsive even before the event arrives. // 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<void> {
if (!vchanRigId) return; if (!vchanRigId) return;
try { try {
const resp = await fetch( const resp = await fetch(
@@ -252,7 +313,7 @@ async function vchanDelete(channelId) {
// Lightweight auto-join for initial connect: registers the session on // Lightweight auto-join for initial connect: registers the session on
// channel 0 without taking scheduler control or reconnecting audio // channel 0 without taking scheduler control or reconnecting audio
// (audio isn't started yet at this point). // (audio isn't started yet at this point).
async function vchanAutoJoinPrimary(channelId) { async function vchanAutoJoinPrimary(channelId: string): Promise<void> {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
try { try {
const resp = await fetch( const resp = await fetch(
@@ -274,7 +335,7 @@ async function vchanAutoJoinPrimary(channelId) {
} }
} }
async function vchanSubscribe(channelId) { async function vchanSubscribe(channelId: string): Promise<void> {
if (!vchanSessionId || !vchanRigId) return; if (!vchanSessionId || !vchanRigId) return;
try { try {
await vchanTakeSchedulerControl(); await vchanTakeSchedulerControl();
@@ -304,43 +365,43 @@ async function vchanSubscribe(channelId) {
// - primary channel: /audio (no param) // - primary channel: /audio (no param)
// Always updates _audioChannelOverride so that starting audio later // Always updates _audioChannelOverride so that starting audio later
// connects to the correct channel. Only reconnects if RX audio is active. // 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, // Always update the override so startRxAudio picks up the right URL,
// even when audio isn't currently running. // even when audio isn't currently running.
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
if (typeof _audioChannelOverride !== "undefined") { vchanWindow._audioChannelOverride = ch?.id ?? null;
_audioChannelOverride = ch ? ch.id : null; if (!vchanWindow.rxActive) return;
} vchanWindow.stopRxAudio?.();
if (typeof rxActive === "undefined" || !rxActive) return;
if (typeof stopRxAudio === "function") stopRxAudio();
// Delay so the server has time to set up the per-channel encoder. // 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 // 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. // just needs to be long enough for the WS upgrade to reach the server.
setTimeout(() => { setTimeout(() => {
if (typeof startRxAudio === "function") startRxAudio(); vchanWindow.startRxAudio?.();
}, 300); }, 300);
} }
// Called by app.js from applyCapabilities(). // Called by app.js from applyCapabilities().
// Shows the channel picker only for SDR rigs. // 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"); const picker = document.getElementById("vchan-picker");
if (!picker) return; if (!picker) return;
picker.style.display = (caps && caps.filter_controls) ? "" : "none"; picker.style.display = (caps && caps.filter_controls) ? "" : "none";
vchanRenderSchedulerRelease(); vchanRenderSchedulerRelease();
} }
vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Freq / mode interception + UI accent // Freq / mode interception + UI accent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Returns true when the active channel is a non-primary (virtual) channel. // Returns true when the active channel is a non-primary (virtual) channel.
function vchanIsOnVirtual() { function vchanIsOnVirtual(): boolean {
if (!vchanActiveId || vchanChannels.length === 0) return false; 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; return vchanChannels.find(c => c.id === vchanActiveId) || null;
} }
@@ -348,10 +409,10 @@ function vchanActiveChannel() {
function vchanUpdateFreqDisplay() { function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel(); const ch = vchanActiveChannel();
if (!ch) return; if (!ch) return;
const el = document.getElementById("freq"); const el = document.getElementById("freq") as HTMLInputElement | null;
if (!el) return; if (!el) return;
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") { if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
el.value = formatFreqForStep(ch.freq_hz, jogUnit); el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else { } else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); 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. // Sync the mode picker to the active virtual channel's mode.
// Called whenever the active channel changes or the channel list is refreshed. // Called whenever the active channel changes or the channel list is refreshed.
function vchanSyncModeDisplay() { function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode"); const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
if (!modeEl) return; if (!modeEl) return;
if (vchanIsOnVirtual()) { if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel(); const ch = vchanActiveChannel();
@@ -368,23 +429,21 @@ function vchanSyncModeDisplay() {
} }
// When on primary channel, app.js rig-state updates handle the picker. // When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase(); const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof lastModeName !== "undefined") { if (typeof vchanWindow.lastModeName === "string") {
if (modeUpper === "WFM" && lastModeName !== "WFM") { if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
if (typeof setJogDivisor === "function") setJogDivisor(10); vchanWindow.setJogDivisor?.(10);
if (typeof resetRdsDisplay === "function") resetRdsDisplay(); vchanWindow.resetRdsDisplay?.();
} else if (modeUpper !== "WFM" && lastModeName === "WFM") { } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
if (typeof resetRdsDisplay === "function") resetRdsDisplay(); vchanWindow.resetRdsDisplay?.();
} }
lastModeName = modeUpper; vchanWindow.lastModeName = modeUpper;
} }
if (typeof updateWfmControls === "function") updateWfmControls(); vchanWindow.updateWfmControls?.();
if (typeof updateSdrSquelchControlVisibility === "function") { vchanWindow.updateSdrSquelchControlVisibility?.();
updateSdrSquelchControlVisibility(); if (vchanWindow.refreshRdsUi) {
} vchanWindow.refreshRdsUi();
if (typeof refreshRdsUi === "function") { } else {
refreshRdsUi(); vchanWindow.positionRdsPsOverlay?.();
} else if (typeof positionRdsPsOverlay === "function") {
positionRdsPsOverlay();
} }
} }
@@ -393,21 +452,16 @@ function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return; if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel(); const ch = vchanActiveChannel();
if (!ch) return; if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input"); const bwEl = document.getElementById("spectrum-bw-input") as HTMLInputElement | null;
if (!bwEl) return; if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode. // bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0; let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && typeof mwDefaultsForMode === "function") { if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
bwHz = mwDefaultsForMode(ch.mode)[0] || 0; bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
} }
if (bwHz > 0) { if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, ""); bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
if (typeof currentBandwidthHz !== "undefined") { vchanWindow.currentBandwidthHz = bwHz;
currentBandwidthHz = bwHz;
window.currentBandwidthHz = bwHz;
} else {
window.currentBandwidthHz = bwHz;
}
} }
} }
@@ -422,27 +476,26 @@ function vchanSyncAccentUI() {
vchanUpdateFreqDisplay(); vchanUpdateFreqDisplay();
vchanSyncModeDisplay(); vchanSyncModeDisplay();
vchanSyncBwDisplay(); vchanSyncBwDisplay();
} else if (typeof _origRefreshFreqDisplay === "function") { } else {
_origRefreshFreqDisplay(); origRefreshFreqDisplay?.();
} }
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
updateDocumentTitle(activeChannelRds()); vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
} }
} }
// Saved reference to the original refreshFreqDisplay from app.js. // 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; if (!vchanRigId || !vchanActiveId) return;
// Validate against current SDR capture window. // Validate against current SDR capture window.
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
lastSpectrumData.sample_rate > 0) { const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
const halfSpan = Number(lastSpectrumData.sample_rate) / 2; const center = vchanWindow.lastSpectrumData.center_hz;
const center = Number(lastSpectrumData.center_hz);
if (Math.abs(freqHz - center) > halfSpan) { if (Math.abs(freqHz - center) > halfSpan) {
if (typeof showHint === "function") { if (vchanWindow.showHint) {
showHint( vchanWindow.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000 3000
); );
@@ -451,18 +504,18 @@ function vchanSetChannelFreq(freqHz) {
} }
} }
// Fire-and-forget: scheduler control + channel freq PUT run in background. // Fire-and-forget: scheduler control + channel freq PUT run in background.
vchanTakeSchedulerControl(); void vchanTakeSchedulerControl();
fetch( void fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) }), 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<void> {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
try { try {
await vchanTakeSchedulerControl(); await vchanTakeSchedulerControl();
@@ -480,7 +533,7 @@ async function vchanSetChannelBandwidth(bwHz) {
} }
} }
async function vchanSetChannelMode(mode) { async function vchanSetChannelMode(mode: string): Promise<void> {
if (!vchanRigId || !vchanActiveId) return; if (!vchanRigId || !vchanActiveId) return;
try { try {
await vchanTakeSchedulerControl(); await vchanTakeSchedulerControl();
@@ -501,7 +554,7 @@ async function vchanSetChannelMode(mode) {
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before // Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
// sending /set_mode to the server. Returns true if the change was handled // sending /set_mode to the server. Returns true if the change was handled
// by the virtual channel (caller should skip the server request). // 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; if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode); await vchanSetChannelMode(mode);
return true; return true;
@@ -509,7 +562,7 @@ window.vchanInterceptMode = async function(mode) {
// Called by app.js bandwidth setters before sending /set_bandwidth to the // Called by app.js bandwidth setters before sending /set_bandwidth to the
// server. Returns true if the change was handled by the virtual channel. // 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; if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz); await vchanSetChannelBandwidth(bwHz);
return true; return true;
@@ -519,23 +572,23 @@ window.vchanInterceptBandwidth = async function(bwHz) {
// frequency changes are redirected to the active virtual channel instead of // frequency changes are redirected to the active virtual channel instead of
// the server when on a non-primary channel. // the server when on a non-primary channel.
(function() { (function() {
const _orig = window.setRigFrequency; const original = vchanWindow.setRigFrequency;
window.setRigFrequency = function(freqHz) { vchanWindow.setRigFrequency = function(freqHz: number) {
if (vchanIsOnVirtual()) { if (vchanIsOnVirtual()) {
// Optimistic local update first, then fire-and-forget channel API. // Optimistic local update first, then fire-and-forget channel API.
if (typeof applyLocalTunedFrequency === "function") { if (vchanWindow.applyLocalTunedFrequency) {
if (typeof _freqOptimisticSeq !== "undefined") { if (typeof vchanWindow._freqOptimisticSeq === "number") {
++_freqOptimisticSeq; vchanWindow._freqOptimisticSeq += 1;
_freqOptimisticHz = Math.round(freqHz); vchanWindow._freqOptimisticHz = Math.round(freqHz);
} }
applyLocalTunedFrequency(Math.round(freqHz)); vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
} }
vchanSetChannelFreq(freqHz); vchanSetChannelFreq(freqHz);
return; return;
} }
// Scheduler control is fire-and-forget — don't block the freq change. // Scheduler control is fire-and-forget — don't block the freq change.
vchanTakeSchedulerControl(); void vchanTakeSchedulerControl();
if (typeof _orig === "function") _orig(freqHz); original?.(freqHz);
}; };
})(); })();
@@ -543,7 +596,7 @@ window.vchanInterceptBandwidth = async function(bwHz) {
const btn = document.getElementById("scheduler-release-btn"); const btn = document.getElementById("scheduler-release-btn");
if (btn) { if (btn) {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
vchanToggleSchedulerRelease(); void vchanToggleSchedulerRelease();
}); });
} }
vchanStartSchedulerReleasePolling(); vchanStartSchedulerReleasePolling();
@@ -554,12 +607,12 @@ window.vchanInterceptBandwidth = async function(bwHz) {
// active virtual channel's frequency (SSE rig-state updates would otherwise // active virtual channel's frequency (SSE rig-state updates would otherwise
// constantly overwrite it with channel 0's freq). // constantly overwrite it with channel 0's freq).
(function() { (function() {
_origRefreshFreqDisplay = window.refreshFreqDisplay; origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
window.refreshFreqDisplay = function() { vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) { if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay(); vchanUpdateFreqDisplay();
return; return;
} }
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay(); origRefreshFreqDisplay?.();
}; };
})(); })();
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
test("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);
});