diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js index 9521e44..1a1330d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js @@ -245,6 +245,7 @@ async function vchanDelete(channelId) { async function vchanSubscribe(channelId) { if (!vchanSessionId || !vchanRigId) return; try { + await vchanTakeSchedulerControl(); const resp = await fetch( `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, { @@ -418,6 +419,7 @@ async function vchanSetChannelFreq(freqHz) { } } try { + await vchanTakeSchedulerControl(); const resp = await fetch( `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, { @@ -435,6 +437,7 @@ async function vchanSetChannelFreq(freqHz) { async function vchanSetChannelBandwidth(bwHz) { if (!vchanRigId || !vchanActiveId) return; try { + await vchanTakeSchedulerControl(); const resp = await fetch( `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, { @@ -452,6 +455,7 @@ async function vchanSetChannelBandwidth(bwHz) { async function vchanSetChannelMode(mode) { if (!vchanRigId || !vchanActiveId) return; try { + await vchanTakeSchedulerControl(); const resp = await fetch( `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`, { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index e547e80..ab40804 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1773,7 +1773,6 @@ body.map-fake-fullscreen-active { max-height: min(22rem, 60vh); overflow: auto; padding: 0.55rem 0.65rem; - border: 1px solid color-mix(in srgb, var(--accent-yellow) 26%, var(--border-light)); border-radius: 0.65rem; background: color-mix(in srgb, var(--card-bg) 84%, transparent); color: var(--text); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index e478882..1e5c7a9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -1256,10 +1256,30 @@ pub async fn subscribe_channel( path: web::Path<(String, Uuid)>, body: web::Json, vchan_mgr: web::Data>, + rig_tx: web::Data>, + bookmark_store: web::Data>, + scheduler_control: web::Data, ) -> impl Responder { + let body = body.into_inner(); let (rig_id, channel_id) = path.into_inner(); match vchan_mgr.subscribe_session(body.session_id, &rig_id, channel_id) { - Some(ch) => HttpResponse::Ok().json(ch), + Some(ch) => { + scheduler_control.set_released(body.session_id, false); + let Some(selected) = vchan_mgr.selected_channel(&rig_id, channel_id) else { + return HttpResponse::InternalServerError().body("subscribed channel missing"); + }; + if let Err(err) = apply_selected_channel( + rig_tx.get_ref(), + &rig_id, + &selected, + bookmark_store.get_ref().as_ref(), + ) + .await + { + return HttpResponse::from_error(err); + } + HttpResponse::Ok().json(ch) + } None => HttpResponse::NotFound().finish(), } } @@ -1665,6 +1685,112 @@ async fn send_command( } } +async fn send_command_to_rig( + rig_tx: &mpsc::Sender, + rig_id: &str, + cmd: RigCommand, +) -> Result<(), Error> { + let (resp_tx, resp_rx) = oneshot::channel(); + rig_tx + .send(RigRequest { + cmd, + respond_to: resp_tx, + rig_id_override: Some(rig_id.to_string()), + }) + .await + .map_err(|e| { + actix_web::error::ErrorInternalServerError(format!("failed to send to rig: {e:?}")) + })?; + + let resp = tokio::time::timeout(REQUEST_TIMEOUT, resp_rx) + .await + .map_err(|_| actix_web::error::ErrorGatewayTimeout("rig response timeout"))?; + + match resp { + Ok(Ok(_)) => Ok(()), + Ok(Err(err)) => Err(actix_web::error::ErrorBadRequest(err.message)), + Err(e) => Err(actix_web::error::ErrorInternalServerError(format!( + "rig response channel error: {e:?}" + ))), + } +} + +fn bookmark_decoder_state( + bookmark: &crate::server::bookmarks::Bookmark, +) -> (bool, bool, bool, bool) { + let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT"); + let mut want_hf_aprs = false; + let mut want_ft8 = false; + let mut want_wspr = false; + + for decoder in bookmark + .decoders + .iter() + .map(|item| item.trim().to_ascii_lowercase()) + { + match decoder.as_str() { + "aprs" => want_aprs = true, + "hf-aprs" => want_hf_aprs = true, + "ft8" => want_ft8 = true, + "wspr" => want_wspr = true, + _ => {} + } + } + + (want_aprs, want_hf_aprs, want_ft8, want_wspr) +} + +async fn apply_selected_channel( + rig_tx: &mpsc::Sender, + rig_id: &str, + channel: &crate::server::vchan::SelectedChannel, + bookmark_store: &crate::server::bookmarks::BookmarkStore, +) -> Result<(), Error> { + send_command_to_rig( + rig_tx, + rig_id, + RigCommand::SetMode(parse_mode(&channel.mode)), + ) + .await?; + + if channel.bandwidth_hz > 0 { + send_command_to_rig( + rig_tx, + rig_id, + RigCommand::SetBandwidth(channel.bandwidth_hz), + ) + .await?; + } + + send_command_to_rig( + rig_tx, + rig_id, + RigCommand::SetFreq(Freq { + hz: channel.freq_hz, + }), + ) + .await?; + + let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else { + return Ok(()); + }; + let Some(bookmark) = bookmark_store.get(bookmark_id) else { + return Ok(()); + }; + let (want_aprs, want_hf_aprs, want_ft8, want_wspr) = bookmark_decoder_state(&bookmark); + let desired = [ + RigCommand::SetAprsDecodeEnabled(want_aprs), + RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs), + RigCommand::SetFt8DecodeEnabled(want_ft8), + RigCommand::SetWsprDecodeEnabled(want_wspr), + ]; + for cmd in desired { + send_command_to_rig(rig_tx, rig_id, cmd).await?; + } + + Ok(()) +} + async fn wait_for_view(mut rx: watch::Receiver) -> Result { if let Some(view) = rx.borrow().snapshot() { return Ok(view); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs index 53c46cc..2d63087 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/vchan.rs @@ -44,6 +44,15 @@ pub struct ClientChannel { pub subscribers: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedChannel { + pub id: Uuid, + pub freq_hz: u64, + pub mode: String, + pub bandwidth_hz: u32, + pub scheduler_bookmark_id: Option, +} + #[derive(Debug, Clone)] pub enum VChanClientError { /// Channel cap would be exceeded. @@ -138,7 +147,7 @@ impl ClientChannelManager { freq_hz: c.freq_hz, mode: c.mode.clone(), bandwidth_hz: c.bandwidth_hz, - permanent: c.permanent, + permanent: c.permanent || c.scheduler_bookmark_id.is_some(), subscribers: c.session_ids.len(), }) .collect(); @@ -194,7 +203,7 @@ impl ClientChannelManager { freq_hz: c.freq_hz, mode: c.mode.clone(), bandwidth_hz: c.bandwidth_hz, - permanent: c.permanent, + permanent: c.permanent || c.scheduler_bookmark_id.is_some(), subscribers: c.session_ids.len(), }) .collect() @@ -289,7 +298,7 @@ impl ClientChannelManager { freq_hz: ch.freq_hz, mode: ch.mode.clone(), bandwidth_hz: ch.bandwidth_hz, - permanent: ch.permanent, + permanent: ch.permanent || ch.scheduler_bookmark_id.is_some(), subscribers: ch.session_ids.len(), }; @@ -331,7 +340,10 @@ impl ClientChannelManager { } let mut idx = 0; while idx < channels.len() { - if !channels[idx].permanent && channels[idx].session_ids.is_empty() { + if !channels[idx].permanent + && channels[idx].scheduler_bookmark_id.is_none() + && channels[idx].session_ids.is_empty() + { removed_channel_ids.push(channels[idx].id); channels.remove(idx); changed = true; @@ -361,7 +373,7 @@ impl ClientChannelManager { .iter() .position(|c| c.id == channel_id) .ok_or(VChanClientError::NotFound)?; - if channels[pos].permanent { + if channels[pos].permanent || channels[pos].scheduler_bookmark_id.is_some() { return Err(VChanClientError::Permanent); } // Collect evicted sessions to clean up the session map. @@ -480,6 +492,20 @@ impl ClientChannelManager { self.sessions.read().unwrap().get(&session_id).cloned() } + /// Return the selected channel's tune metadata. + pub fn selected_channel(&self, rig_id: &str, channel_id: Uuid) -> Option { + let rigs = self.rigs.read().unwrap(); + let channels = rigs.get(rig_id)?; + let channel = channels.iter().find(|channel| channel.id == channel_id)?; + Some(SelectedChannel { + id: channel.id, + freq_hz: channel.freq_hz, + mode: channel.mode.clone(), + bandwidth_hz: channel.bandwidth_hz, + scheduler_bookmark_id: channel.scheduler_bookmark_id.clone(), + }) + } + /// Reconcile visible scheduler-managed channels for a rig. /// /// These channels are user-visible virtual channels sourced from the @@ -638,6 +664,59 @@ mod tests { assert_eq!(channels[1].mode, "DIG"); assert_eq!(channels[1].bandwidth_hz, 3_000); assert_eq!(channels[1].subscribers, 0); - assert!(!channels[1].permanent); + assert!(channels[1].permanent); + } + + #[test] + fn release_session_keeps_scheduler_managed_channels() { + let mgr = ClientChannelManager::new(4); + let rig_id = "rig-a"; + let session_id = Uuid::new_v4(); + + mgr.init_rig(rig_id, 14_074_000, "USB"); + let _channel = mgr + .allocate(session_id, rig_id, 14_075_000, "DIG") + .expect("allocate vchan"); + mgr.sync_scheduler_channels( + rig_id, + &[("bm-ft8".to_string(), 14_074_000, "DIG".to_string(), 3_000)], + ); + + mgr.release_session(session_id); + + let channels = mgr.channels(rig_id); + assert_eq!(channels.len(), 2); + assert_eq!(channels[1].mode, "DIG"); + assert_eq!(channels[1].subscribers, 0); + } + + #[test] + fn subscribed_scheduler_channel_survives_scheduler_clear_until_released() { + let mgr = ClientChannelManager::new(4); + let rig_id = "rig-a"; + let session_id = Uuid::new_v4(); + + mgr.init_rig(rig_id, 14_074_000, "USB"); + mgr.sync_scheduler_channels( + rig_id, + &[("bm-aprs".to_string(), 144_800_000, "PKT".to_string(), 12_500)], + ); + + let channel_id = mgr.channels(rig_id)[1].id; + mgr.subscribe_session(session_id, rig_id, channel_id) + .expect("subscribe scheduler channel"); + + mgr.sync_scheduler_channels(rig_id, &[]); + + let channels = mgr.channels(rig_id); + assert_eq!(channels.len(), 2); + assert_eq!(channels[1].id, channel_id); + assert_eq!(channels[1].subscribers, 1); + + mgr.release_session(session_id); + mgr.sync_scheduler_channels(rig_id, &[]); + + let channels = mgr.channels(rig_id); + assert_eq!(channels.len(), 1); } }