[feat](trx-client): support virtual channel bandwidth control

Add client-side command plumbing, HTTP endpoint handling, and frontend interception so bandwidth changes are applied per active virtual channel and survive reconnects.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-11 21:41:32 +01:00
parent 717228a635
commit daa0631b35
7 changed files with 150 additions and 12 deletions
@@ -3278,6 +3278,7 @@ async function applyBandwidthFromInput() {
syncBandwidthInput(clamped);
if (lastSpectrumData) scheduleSpectrumDraw();
try {
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(clamped)) return;
await postPath(`/set_bandwidth?hz=${clamped}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
@@ -3352,6 +3353,7 @@ async function applyAutoBandwidth() {
syncBandwidthInput(estimated);
if (lastSpectrumData) scheduleSpectrumDraw();
try {
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
await postPath(`/set_bandwidth?hz=${estimated}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
@@ -7774,9 +7776,12 @@ if (spectrumCanvas || overviewCanvas) {
window.addEventListener("mouseup", async () => {
if (_bwDragEdge) {
try {
await postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
const bwHz = Math.round(currentBandwidthHz);
if (!(typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(bwHz))) {
await postPath(`/set_bandwidth?hz=${bwHz}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
}
}
} catch (_) {}
_bwDragEdge = null;
@@ -286,10 +286,17 @@ async function bmApply(bm) {
modeEl.value = String(bm.mode || "").toUpperCase();
}
if (bm.bandwidth_hz) {
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
if (!bwHandledByVchan) {
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
}
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bm.bandwidth_hz;
}
if (typeof window !== "undefined") {
window.currentBandwidthHz = bm.bandwidth_hz;
}
if (typeof syncBandwidthInput === "function") {
syncBandwidthInput(bm.bandwidth_hz);
}
@@ -236,6 +236,29 @@ function vchanSyncModeDisplay() {
// When on primary channel, app.js rig-state updates handle the picker.
}
// 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");
if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bwHz;
window.currentBandwidthHz = bwHz;
} else {
window.currentBandwidthHz = bwHz;
}
}
}
// Add / remove the vchan accent class from the freq and BW inputs.
function vchanSyncAccentUI() {
const onVirtual = vchanIsOnVirtual();
@@ -246,6 +269,7 @@ function vchanSyncAccentUI() {
if (onVirtual) {
vchanUpdateFreqDisplay();
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else if (typeof _origRefreshFreqDisplay === "function") {
_origRefreshFreqDisplay();
}
@@ -286,6 +310,23 @@ async function vchanSetChannelFreq(freqHz) {
}
}
async function vchanSetChannelBandwidth(bwHz) {
if (!vchanRigId || !vchanActiveId) return;
try {
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 {
@@ -312,6 +353,14 @@ window.vchanInterceptMode = async function(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.
window.vchanInterceptBandwidth = async function(bwHz) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz);
return true;
};
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
// frequency changes are redirected to the active virtual channel instead of
// the server when on a non-primary channel.
@@ -1202,6 +1202,27 @@ pub async fn set_vchan_freq(
}
}
#[derive(serde::Deserialize)]
struct SetChanBwBody {
bandwidth_hz: u32,
}
#[put("/channels/{rig_id}/{channel_id}/bw")]
pub async fn set_vchan_bw(
path: web::Path<(String, Uuid)>,
body: web::Json<SetChanBwBody>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
) -> impl Responder {
let (rig_id, channel_id) = path.into_inner();
match vchan_mgr.set_channel_bandwidth(&rig_id, channel_id, body.bandwidth_hz) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => {
HttpResponse::NotFound().finish()
}
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[derive(serde::Deserialize)]
struct SetChanModeBody {
mode: String,
@@ -1297,6 +1318,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(delete_channel_route)
.service(subscribe_channel)
.service(set_vchan_freq)
.service(set_vchan_bw)
.service(set_vchan_mode)
// Auth endpoints
.service(crate::server::auth::login)
@@ -36,6 +36,8 @@ pub struct ClientChannel {
pub index: usize,
pub freq_hz: u64,
pub mode: String,
/// Audio filter bandwidth in Hz (0 = mode default).
pub bandwidth_hz: u32,
/// True for channel 0 — cannot be deleted.
pub permanent: bool,
/// Number of SSE sessions currently subscribed to this channel.
@@ -72,6 +74,8 @@ struct InternalChannel {
id: Uuid,
freq_hz: u64,
mode: String,
/// Audio filter bandwidth in Hz (0 = mode default).
bandwidth_hz: u32,
permanent: bool,
/// Session UUIDs currently subscribed to this channel.
session_ids: Vec<Uuid>,
@@ -132,6 +136,7 @@ impl ClientChannelManager {
index: idx,
freq_hz: c.freq_hz,
mode: c.mode.clone(),
bandwidth_hz: c.bandwidth_hz,
permanent: c.permanent,
subscribers: c.session_ids.len(),
})
@@ -153,6 +158,7 @@ impl ClientChannelManager {
id: Uuid::new_v4(),
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
permanent: true,
session_ids: Vec::new(),
});
@@ -185,6 +191,7 @@ impl ClientChannelManager {
index: idx,
freq_hz: c.freq_hz,
mode: c.mode.clone(),
bandwidth_hz: c.bandwidth_hz,
permanent: c.permanent,
subscribers: c.session_ids.len(),
})
@@ -218,6 +225,7 @@ impl ClientChannelManager {
id,
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
permanent: false,
session_ids: vec![session_id],
});
@@ -227,6 +235,7 @@ impl ClientChannelManager {
index: idx,
freq_hz,
mode: mode.to_string(),
bandwidth_hz: 0,
permanent: false,
subscribers: 1,
};
@@ -276,6 +285,7 @@ impl ClientChannelManager {
index: idx,
freq_hz: ch.freq_hz,
mode: ch.mode.clone(),
bandwidth_hz: ch.bandwidth_hz,
permanent: ch.permanent,
subscribers: ch.session_ids.len(),
};
@@ -427,6 +437,25 @@ impl ClientChannelManager {
Ok(())
}
pub fn set_channel_bandwidth(
&self,
rig_id: &str,
channel_id: Uuid,
bandwidth_hz: u32,
) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap();
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let ch = channels
.iter_mut()
.find(|c| c.id == channel_id)
.ok_or(VChanClientError::NotFound)?;
ch.bandwidth_hz = bandwidth_hz;
self.broadcast_change(rig_id, channels);
drop(rigs);
self.send_audio_cmd(VChanAudioCmd::SetBandwidth { uuid: channel_id, bandwidth_hz });
Ok(())
}
/// Return the channel a session is currently subscribed to.
pub fn session_channel(&self, session_id: Uuid) -> Option<(String, Uuid)> {
self.sessions.read().unwrap().get(&session_id).cloned()