Mode was a full-width select: 483px of the row to display "FM". The modes are three or four characters and there are at most twelve, so they become a segmented group like the Unit and Step Scale pickers beside them — a third of the width, and one click instead of two. The <select> stays as the mode's value. A dozen call sites and several plugins read #mode.value, so replacing it outright would have reached much further than a layout change should; it is hidden from sight and from assistive tech, the buttons write to it, and everything downstream runs unchanged. Every writer re-syncs the buttons, the plugins through a new trxCore.syncModePicker. The row itself was a grid with a track per column, but the WFM, SAM and transmit columns are hidden on most rigs, so it ended in some 500px of hole. It packs left now. Same fault one level down: the power buttons sat in three fixed tracks, so a rig with neither transmit nor lock kept two empty ones and left its label chip stranded at the far edge. Unit and Step Scale move out of the frequency row and in beside the wheel and the +/- they modify, which were some 600px away. Signed-off-by: Stan Grams <sjg@haxx.space>
594 lines
21 KiB
TypeScript
594 lines
21 KiB
TypeScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import { hostCore, hostState } from "./host.js";
|
|
|
|
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;
|
|
}
|
|
// Callbacks the application publishes as documented transitional window
|
|
// properties; all other application state and services arrive through the
|
|
// typed host contract in ./host.ts.
|
|
interface VirtualChannelBridge {
|
|
renderRdsOverlays?: () => void;
|
|
refreshRdsUi?: () => void;
|
|
trx?: { modules?: { vchan?: VirtualChannelService } };
|
|
}
|
|
|
|
interface VirtualChannelService {
|
|
readonly channels: readonly VirtualChannel[];
|
|
readonly activeId: string | null;
|
|
activeChannel(): VirtualChannel | null;
|
|
applyCapabilities(capabilities: { filter_controls?: boolean } | null): void;
|
|
handleSession(data: string): void;
|
|
handleChannels(data: string): void;
|
|
isOnVirtual(): boolean;
|
|
interceptMode(mode: string): Promise<boolean>;
|
|
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
|
interceptFrequency(frequencyHz: number): boolean;
|
|
interceptFreqDisplay(): boolean;
|
|
takeSchedulerControl(): Promise<void>;
|
|
releaseToScheduler(): Promise<void>;
|
|
}
|
|
const vchanWindow = window as unknown as VirtualChannelBridge;
|
|
|
|
// --- Virtual Channels Plugin ---
|
|
//
|
|
// Handles the `session` and `channels` SSE events emitted by /events and
|
|
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
|
|
|
let vchanSessionId: string | null = null;
|
|
let vchanRigId: string | null = null;
|
|
let vchanChannels: VirtualChannel[] = [];
|
|
let vchanActiveId: string | null = null;
|
|
let schedulerReleaseState: SchedulerReleaseState | null = null;
|
|
let schedulerReleasePollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function vchanFmtFreq(hz: number): string {
|
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
|
return `${String(hz)}\u202fHz`;
|
|
}
|
|
|
|
function schedulerReleaseSummaryText(state: SchedulerReleaseState | null): string {
|
|
if (!state) return "Scheduler is controlling the rig.";
|
|
const connected = Number(state.connected_sessions) || 0;
|
|
const released = Number(state.released_sessions) || 0;
|
|
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") as HTMLButtonElement | null;
|
|
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(): Promise<void> {
|
|
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() as SchedulerReleaseState;
|
|
vchanRenderSchedulerRelease();
|
|
} catch (e) {
|
|
console.error("scheduler release status failed", e);
|
|
}
|
|
}
|
|
|
|
function vchanStartSchedulerReleasePolling() {
|
|
if (schedulerReleasePollTimer) {
|
|
clearInterval(schedulerReleasePollTimer);
|
|
}
|
|
schedulerReleasePollTimer = setInterval(() => { void vchanPollSchedulerRelease(); }, 10000);
|
|
}
|
|
|
|
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() as SchedulerReleaseState;
|
|
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() as SchedulerReleaseState;
|
|
vchanRenderSchedulerRelease();
|
|
} catch (e) {
|
|
console.error("scheduler control takeover failed", e);
|
|
}
|
|
}
|
|
|
|
// Called by app.js when the SSE `session` event arrives.
|
|
function vchanHandleSession(data: string): void {
|
|
try {
|
|
const d = JSON.parse(data) as SessionEvent;
|
|
vchanSessionId = d.session_id || null;
|
|
void vchanPollSchedulerRelease();
|
|
} catch (e) {
|
|
console.warn("vchan: bad session event", e);
|
|
}
|
|
}
|
|
|
|
// Called by app.js when the SSE `channels` event arrives.
|
|
function vchanHandleChannels(data: string): void {
|
|
try {
|
|
const d = JSON.parse(data) as ChannelsEvent;
|
|
vchanRigId = d.remote || null;
|
|
vchanChannels = d.channels || [];
|
|
const ids = new Set(vchanChannels.map(c => c.id));
|
|
const primaryChannel = vchanChannels[0];
|
|
if (!vchanActiveId && primaryChannel && vchanSessionId) {
|
|
// First channels event for this session — auto-subscribe to channel 0
|
|
// so we join the same tuned channel as other users on this rig.
|
|
// Use a direct subscribe (no scheduler control takeover) to avoid
|
|
// side-effects on initial connect.
|
|
void vchanAutoJoinPrimary(primaryChannel.id);
|
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
|
// Active channel was evicted — fall back to channel 0 and reconnect audio.
|
|
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 = "\u00d7";
|
|
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);
|
|
});
|
|
|
|
// "+" button — allocate a new channel at the current VFO frequency.
|
|
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;
|
|
|
|
// Use the last known rig frequency and mode as the starting point.
|
|
const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0)
|
|
? hostState.lastFreqHz
|
|
: 0;
|
|
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
|
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() as VirtualChannel;
|
|
vchanActiveId = ch.id;
|
|
// The SSE `channels` event will trigger vchanRender(); optimistically
|
|
// mark active so the picker feels responsive even before the event arrives.
|
|
vchanRender();
|
|
vchanReconnectAudio();
|
|
} catch (e) {
|
|
console.error("vchan: allocate error", e);
|
|
}
|
|
}
|
|
|
|
async function vchanDelete(channelId: string): Promise<void> {
|
|
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);
|
|
}
|
|
// Channel list updates via SSE `channels` event.
|
|
} catch (e) {
|
|
console.error("vchan: delete error", e);
|
|
}
|
|
}
|
|
|
|
// Lightweight auto-join for initial connect: registers the session on
|
|
// channel 0 without taking scheduler control or reconnecting audio
|
|
// (audio isn't started yet at this point).
|
|
async function vchanAutoJoinPrimary(channelId: string): Promise<void> {
|
|
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: string): Promise<void> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Reconnect the audio WebSocket to the appropriate endpoint:
|
|
// - virtual channel: /audio?channel_id=<uuid>
|
|
// - primary channel: /audio (no param)
|
|
// Always updates _audioChannelOverride so that starting audio later
|
|
// connects to the correct channel. Only reconnects if RX audio is active.
|
|
function vchanReconnectAudio(): void {
|
|
// Always update the override so startRxAudio picks up the right URL,
|
|
// even when audio isn't currently running.
|
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
|
hostState.audioChannelOverride = ch?.id ?? null;
|
|
if (!hostState.rxActive) return;
|
|
hostCore.stopRxAudio();
|
|
// Delay so the server has time to set up the per-channel encoder.
|
|
// The server-side audio_ws handler also polls for up to 2 s, so this
|
|
// just needs to be long enough for the WS upgrade to reach the server.
|
|
setTimeout(() => {
|
|
hostCore.startRxAudio();
|
|
}, 300);
|
|
}
|
|
|
|
// Called by app.js from applyCapabilities().
|
|
// Shows the channel picker only for SDR rigs.
|
|
function vchanApplyCapabilities(caps: { filter_controls?: boolean } | null): void {
|
|
const picker = document.getElementById("vchan-picker");
|
|
if (!picker) return;
|
|
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
|
|
vchanRenderSchedulerRelease();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Freq / mode interception + UI accent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Returns true when the active channel is a non-primary (virtual) channel.
|
|
function vchanIsOnVirtual(): boolean {
|
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
|
return vchanActiveId !== vchanChannels[0]?.id;
|
|
}
|
|
|
|
function vchanActiveChannel(): VirtualChannel | null {
|
|
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
|
}
|
|
|
|
// Update the main freq input to show the virtual channel's frequency.
|
|
function vchanUpdateFreqDisplay() {
|
|
const ch = vchanActiveChannel();
|
|
if (!ch) return;
|
|
const el = document.getElementById("freq") as HTMLInputElement | null;
|
|
if (!el) return;
|
|
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
|
}
|
|
|
|
// Sync the mode picker to the active virtual channel's mode.
|
|
// Called whenever the active channel changes or the channel list is refreshed.
|
|
function vchanSyncModeDisplay() {
|
|
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
|
if (!modeEl) return;
|
|
if (vchanIsOnVirtual()) {
|
|
const ch = vchanActiveChannel();
|
|
if (ch && ch.mode) {
|
|
modeEl.value = ch.mode.toUpperCase();
|
|
hostCore.syncModePicker();
|
|
}
|
|
}
|
|
// When on primary channel, app.js rig-state updates handle the picker.
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Sync the BW input to the active virtual channel's bandwidth.
|
|
function vchanSyncBwDisplay() {
|
|
if (!vchanIsOnVirtual()) return;
|
|
const ch = vchanActiveChannel();
|
|
if (!ch) return;
|
|
const bwEl = document.getElementById("spectrum-bw-input") as HTMLInputElement | null;
|
|
if (!bwEl) return;
|
|
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
|
let bwHz = ch.bandwidth_hz || 0;
|
|
if (bwHz === 0) {
|
|
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
|
}
|
|
if (bwHz > 0) {
|
|
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
|
hostState.currentBandwidthHz = bwHz;
|
|
}
|
|
}
|
|
|
|
// Add / remove the vchan accent class from the freq and BW inputs.
|
|
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: number): void {
|
|
if (!vchanRigId || !vchanActiveId) return;
|
|
// Validate against current SDR capture window.
|
|
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)`,
|
|
3000
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
// Fire-and-forget: scheduler control + channel freq PUT run in background.
|
|
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: unknown) => { console.error("vchan: set freq error", error); });
|
|
}
|
|
|
|
async function vchanSetChannelBandwidth(bwHz: number): Promise<void> {
|
|
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: string): Promise<void> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
|
// sending /set_mode to the server. Returns true if the change was handled
|
|
// by the virtual channel (caller should skip the server request).
|
|
async function vchanInterceptMode(mode: string): Promise<boolean> {
|
|
if (!vchanIsOnVirtual()) return false;
|
|
await vchanSetChannelMode(mode);
|
|
return true;
|
|
}
|
|
|
|
// Called by app.js bandwidth setters before sending /set_bandwidth to the
|
|
// server. Returns true if the change was handled by the virtual channel.
|
|
async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
|
|
if (!vchanIsOnVirtual()) return false;
|
|
await vchanSetChannelBandwidth(bwHz);
|
|
return true;
|
|
}
|
|
|
|
// Called by setRigFrequency before it posts /set_freq. When a non-primary
|
|
// channel is active the change belongs to the channel API instead of the rig,
|
|
// so this applies the optimistic local update and reports the tune as handled.
|
|
function vchanInterceptFrequency(freqHz: number): boolean {
|
|
if (!vchanIsOnVirtual()) return false;
|
|
const targetHz = Math.round(freqHz);
|
|
hostCore.armOptimisticFrequency(targetHz);
|
|
hostCore.applyLocalTunedFrequency(targetHz);
|
|
vchanSetChannelFreq(freqHz);
|
|
return true;
|
|
}
|
|
|
|
// Called by the application's refreshFreqDisplay. While a virtual channel is
|
|
// active the field belongs to that channel, so report the render as handled.
|
|
function vchanInterceptFreqDisplay(): boolean {
|
|
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();
|
|
})();
|
|
|