refactor: convert virtual channels to TypeScript
This commit is contained in:
@@ -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,453 +1,459 @@
|
|||||||
"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;
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
var schedulerReleaseState = null;
|
||||||
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
var schedulerReleasePollTimer = null;
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
function vchanFmtFreq(hz) {
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
return hz + " Hz";
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
||||||
}
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
||||||
function schedulerReleaseSummaryText(state) {
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
if (!state) return "Scheduler is controlling the rig.";
|
return `${String(hz)} Hz`;
|
||||||
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.`;
|
|
||||||
}
|
}
|
||||||
if (!state.current_session_released) {
|
function schedulerReleaseSummaryText(state) {
|
||||||
const othersReleased = Math.max(released, 0);
|
if (!state) return "Scheduler is controlling the rig.";
|
||||||
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 connected = Number(state.connected_sessions) || 0;
|
||||||
}
|
const released = Number(state.released_sessions) || 0;
|
||||||
const blocking = Math.max(connected - released, 0);
|
if (connected === 0) return "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.";
|
if (state.all_released) {
|
||||||
}
|
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
vchanRender();
|
if (!state.current_session_released) {
|
||||||
vchanRenderSchedulerRelease();
|
const othersReleased = Math.max(released, 0);
|
||||||
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
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.";
|
||||||
} catch (e) {
|
}
|
||||||
console.warn("vchan: bad channels event", e);
|
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() {
|
||||||
function vchanRender() {
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
const picker = document.getElementById("vchan-picker");
|
const status = document.getElementById("scheduler-release-status");
|
||||||
if (!picker) return;
|
if (!btn || !status) return;
|
||||||
picker.innerHTML = "";
|
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||||
vchanChannels.forEach((ch) => {
|
btn.disabled = !vchanSessionId || currentReleased;
|
||||||
const btn = document.createElement("button");
|
btn.classList.toggle("active", !currentReleased);
|
||||||
btn.type = "button";
|
btn.textContent = "Release to Scheduler";
|
||||||
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||||
if (ch.id === vchanActiveId) btn.classList.add("active");
|
}
|
||||||
const label = document.createElement("span");
|
async function vchanPollSchedulerRelease() {
|
||||||
label.className = "vchan-label";
|
if (!vchanSessionId) {
|
||||||
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
schedulerReleaseState = null;
|
||||||
btn.appendChild(label);
|
vchanRenderSchedulerRelease();
|
||||||
if (!ch.permanent) {
|
return;
|
||||||
const del = document.createElement("span");
|
}
|
||||||
del.className = "vchan-del";
|
try {
|
||||||
del.textContent = "×";
|
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||||
del.title = "Delete channel";
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
del.addEventListener("click", (e) => {
|
schedulerReleaseState = await resp.json();
|
||||||
e.stopPropagation();
|
vchanRenderSchedulerRelease();
|
||||||
vchanDelete(ch.id);
|
} 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);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
}
|
schedulerReleaseState = await resp.json();
|
||||||
btn.addEventListener("click", () => {
|
vchanRenderSchedulerRelease();
|
||||||
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
} catch (e) {
|
||||||
});
|
console.error("scheduler release toggle failed", e);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
async function vchanTakeSchedulerControl() {
|
||||||
function vchanSyncAccentUI() {
|
if (!vchanSessionId) return;
|
||||||
const onVirtual = vchanIsOnVirtual();
|
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||||
const freqEl = document.getElementById("freq");
|
try {
|
||||||
const bwEl = document.getElementById("spectrum-bw-input");
|
const resp = await fetch("/scheduler-control", {
|
||||||
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`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
|
body: JSON.stringify({ session_id: vchanSessionId, released: false })
|
||||||
}
|
});
|
||||||
);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
schedulerReleaseState = await resp.json();
|
||||||
} catch (e) {
|
vchanRenderSchedulerRelease();
|
||||||
console.error("vchan: set bw error", e);
|
} catch (e) {
|
||||||
|
console.error("scheduler control takeover failed", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||||
async function vchanSetChannelMode(mode) {
|
function vchanHandleSession(data) {
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
try {
|
||||||
try {
|
const d = JSON.parse(data);
|
||||||
await vchanTakeSchedulerControl();
|
vchanSessionId = d.session_id || null;
|
||||||
const resp = await fetch(
|
void vchanPollSchedulerRelease();
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
} catch (e) {
|
||||||
{
|
console.warn("vchan: bad session event", e);
|
||||||
method: "PUT",
|
}
|
||||||
|
}
|
||||||
|
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" },
|
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;
|
||||||
}
|
}
|
||||||
);
|
const ch = await resp.json();
|
||||||
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
vchanActiveId = ch.id;
|
||||||
} catch (e) {
|
vchanRender();
|
||||||
console.error("vchan: set mode error", e);
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: allocate error", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
async function vchanDelete(channelId) {
|
||||||
window.vchanInterceptMode = async function(mode) {
|
if (!vchanRigId) return;
|
||||||
if (!vchanIsOnVirtual()) return false;
|
try {
|
||||||
await vchanSetChannelMode(mode);
|
const resp = await fetch(
|
||||||
return true;
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||||
};
|
{ method: "DELETE" }
|
||||||
window.vchanInterceptBandwidth = async function(bwHz) {
|
);
|
||||||
if (!vchanIsOnVirtual()) return false;
|
if (!resp.ok) {
|
||||||
await vchanSetChannelBandwidth(bwHz);
|
console.warn("vchan: delete failed", resp.status);
|
||||||
return true;
|
}
|
||||||
};
|
} catch (e) {
|
||||||
(function() {
|
console.error("vchan: delete error", e);
|
||||||
const _orig = window.setRigFrequency;
|
}
|
||||||
window.setRigFrequency = function(freqHz) {
|
}
|
||||||
if (vchanIsOnVirtual()) {
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
if (typeof applyLocalTunedFrequency === "function") {
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
if (typeof _freqOptimisticSeq !== "undefined") {
|
try {
|
||||||
++_freqOptimisticSeq;
|
const resp = await fetch(
|
||||||
_freqOptimisticHz = Math.round(freqHz);
|
`/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);
|
vchanActiveId = channelId;
|
||||||
return;
|
vchanRender();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: auto-join error", e);
|
||||||
}
|
}
|
||||||
vchanTakeSchedulerControl();
|
}
|
||||||
if (typeof _orig === "function") _orig(freqHz);
|
async function vchanSubscribe(channelId) {
|
||||||
};
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
})();
|
try {
|
||||||
(function initSchedulerReleaseControl() {
|
await vchanTakeSchedulerControl();
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
const resp = await fetch(
|
||||||
if (btn) {
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
btn.addEventListener("click", () => {
|
{
|
||||||
vchanToggleSchedulerRelease();
|
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();
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
vchanRenderSchedulerRelease();
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
})();
|
try {
|
||||||
(function() {
|
await vchanTakeSchedulerControl();
|
||||||
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
const resp = await fetch(
|
||||||
window.refreshFreqDisplay = function() {
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
if (vchanIsOnVirtual()) {
|
{
|
||||||
vchanUpdateFreqDisplay();
|
method: "PUT",
|
||||||
return;
|
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?.();
|
||||||
|
};
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
+162
-109
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user