CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
The bookmark fix addressed one instance of a defect the TypeScript migration left across the feature entries. app.js stopped being a classic script, so its top-level declarations are no longer shared globals, but the converted entries kept reading them as window properties that nothing publishes. Restore the broken behavior: - ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as undefined, so every positioned packet rendered an empty distance. - ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined postPath, so clear-history and decoder toggles threw. - scheduler read authRole as undefined, so the lazy-load path never self-initialized and the Settings tab opened an inert scheduler. - background-decode read authEnabled as undefined, so control gating fell back to role-only. - vchan read fifteen application values and services as undefined: mode and bandwidth sync, the out-of-band hint, RX audio restart, and the frequency field all silently no-opped on a virtual channel. - vchan wrapped window.refreshFreqDisplay, capturing an undefined original exactly as it did for setRigFrequency, so leaving a channel never restored the application's own frequency display. - _audioChannelOverride was a const that nothing could assign, so RX audio always subscribed to the primary channel. - ftx-family read fmtTime, a helper legacy ft8.js owned locally, so decode bar timestamps rendered empty. Declare the contract once in plugins/host.ts and import it from the feature entries, rather than restoring globals that docs/frontend-architecture.md excludes. trx.state gains jogUnit, rxActive and audioChannelOverride, and makes lastModeName writable; trx.core gains the tuning, RDS, WFM, jog and RX audio services the entries need. vchan interception moves to an interceptFreqDisplay service method that refreshFreqDisplay calls, matching the frequency, mode and bandwidth interception it already registers. Reading registry-built elements through a strict lookup is the same defect as in bookmarks: renderTimelineNeedle guards its result, but schedulerEl throws, so the now-initializing scheduler crashed on the timeline needle group that its own SVG creates. Feature tests move onto a shared host fixture, and entries that now import a common module are bundled through bundleEntry like the other shared-module entries. Covers scheduler self-initialization and the distance path that the bare window reads broke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams <sjg@haxx.space>
591 lines
21 KiB
TypeScript
591 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();
|
|
}
|
|
// 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();
|
|
})();
|
|
|