import { hostCore, hostState } from "./chunk-KL66PICH.js"; // src/plugins/vchan.ts var vchanWindow = window; var vchanSessionId = null; var vchanRigId = null; var vchanChannels = []; var vchanActiveId = null; var schedulerReleaseState = null; var schedulerReleasePollTimer = null; function vchanFmtFreq(hz) { if (!Number.isFinite(hz) || hz <= 0) return "--"; if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; return `${String(hz)} Hz`; } function schedulerReleaseSummaryText(state) { if (!state) return "Scheduler is controlling the rig."; const connected = Number(state.connected_sessions) || 0; const released = Number(state.released_sessions) || 0; if (connected === 0) return "Scheduler can control the rig."; if (state.all_released) { return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`; } if (!state.current_session_released) { const othersReleased = Math.max(released, 0); return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; } const blocking = Math.max(connected - released, 0); return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; } function vchanRenderSchedulerRelease() { const btn = document.getElementById("scheduler-release-btn"); const status = document.getElementById("scheduler-release-status"); if (!btn || !status) return; const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); btn.disabled = !vchanSessionId || currentReleased; btn.classList.toggle("active", !currentReleased); btn.textContent = "Release to Scheduler"; status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); } async function vchanPollSchedulerRelease() { if (!vchanSessionId) { schedulerReleaseState = null; vchanRenderSchedulerRelease(); return; } try { const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); schedulerReleaseState = await resp.json(); vchanRenderSchedulerRelease(); } catch (e) { console.error("scheduler release status failed", e); } } function vchanStartSchedulerReleasePolling() { if (schedulerReleasePollTimer) { clearInterval(schedulerReleasePollTimer); } schedulerReleasePollTimer = setInterval(() => { void vchanPollSchedulerRelease(); }, 1e4); } async function vchanToggleSchedulerRelease() { if (!vchanSessionId) return; const rigId = vchanRigId || hostState.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); } } function vchanHandleSession(data) { try { const d = JSON.parse(data); vchanSessionId = d.session_id || null; void vchanPollSchedulerRelease(); } catch (e) { console.warn("vchan: bad session event", e); } } function vchanHandleChannels(data) { try { const d = JSON.parse(data); vchanRigId = d.remote || null; vchanChannels = d.channels || []; const ids = new Set(vchanChannels.map((c) => c.id)); const primaryChannel = vchanChannels[0]; if (!vchanActiveId && primaryChannel && vchanSessionId) { void vchanAutoJoinPrimary(primaryChannel.id); } else if (vchanActiveId && !ids.has(vchanActiveId)) { vchanActiveId = vchanChannels[0]?.id ?? null; vchanReconnectAudio(); } vchanRender(); vchanRenderSchedulerRelease(); vchanWindow.renderRdsOverlays?.(); } catch (e) { console.warn("vchan: bad channels event", e); } } 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(); hostCore.updateDocumentTitle(hostCore.activeChannelRds()); vchanRenderSchedulerRelease(); } async function vchanAllocate() { if (!vchanSessionId || !vchanRigId) return; const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.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; hostState.audioChannelOverride = ch?.id ?? null; if (!hostState.rxActive) return; hostCore.stopRxAudio(); setTimeout(() => { hostCore.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; el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit); } 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 hostState.lastModeName === "string") { if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") { hostCore.setJogDivisor(10); hostCore.resetRdsDisplay(); } else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") { hostCore.resetRdsDisplay(); } hostState.lastModeName = modeUpper; } hostCore.updateWfmControls(); hostCore.updateSdrSquelchControlVisibility(); if (vchanWindow.refreshRdsUi) { vchanWindow.refreshRdsUi(); } else { hostCore.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) { bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0; } if (bwHz > 0) { bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); hostState.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 { hostCore.refreshFreqDisplay(); } hostCore.updateDocumentTitle(hostCore.activeChannelRds()); } function vchanSetChannelFreq(freqHz) { if (!vchanRigId || !vchanActiveId) return; if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) { const halfSpan = hostState.lastSpectrumData.sample_rate / 2; const center = hostState.lastSpectrumData.center_hz; if (Math.abs(freqHz - center) > halfSpan) { hostCore.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); }); } async function vchanSetChannelBandwidth(bwHz) { if (!vchanRigId || !vchanActiveId) return; try { await vchanTakeSchedulerControl(); const resp = await fetch( `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }) } ); if (!resp.ok) console.warn("vchan: set bw failed", resp.status); } catch (e) { console.error("vchan: set bw error", e); } } 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); } } async function vchanInterceptMode(mode) { if (!vchanIsOnVirtual()) return false; await vchanSetChannelMode(mode); return true; } async function vchanInterceptBandwidth(bwHz) { if (!vchanIsOnVirtual()) return false; await vchanSetChannelBandwidth(bwHz); return true; } function vchanInterceptFrequency(freqHz) { if (!vchanIsOnVirtual()) return false; const targetHz = Math.round(freqHz); hostCore.armOptimisticFrequency(targetHz); hostCore.applyLocalTunedFrequency(targetHz); vchanSetChannelFreq(freqHz); return true; } function vchanInterceptFreqDisplay() { if (!vchanIsOnVirtual()) return false; vchanUpdateFreqDisplay(); return true; } vchanWindow.trx ??= {}; vchanWindow.trx.modules ??= {}; vchanWindow.trx.modules.vchan = { get channels() { return vchanChannels; }, get activeId() { return vchanActiveId; }, activeChannel: vchanActiveChannel, applyCapabilities: vchanApplyCapabilities, handleSession: vchanHandleSession, handleChannels: vchanHandleChannels, isOnVirtual: vchanIsOnVirtual, interceptMode: vchanInterceptMode, interceptBandwidth: vchanInterceptBandwidth, interceptFrequency: vchanInterceptFrequency, interceptFreqDisplay: vchanInterceptFreqDisplay, takeSchedulerControl: vchanTakeSchedulerControl, releaseToScheduler: vchanToggleSchedulerRelease }; (function initSchedulerReleaseControl() { const btn = document.getElementById("scheduler-release-btn"); if (btn) { btn.addEventListener("click", () => { void vchanToggleSchedulerRelease(); }); } vchanStartSchedulerReleasePolling(); vchanRenderSchedulerRelease(); })();