[fix](trx-frontend-http): fix scheduler virtual channel selection
Apply scheduler-backed virtual channels as real manual selections so they take control, retune the rig, and restore bookmark decoder state including APRS/PKT. Also remove the inner border from the map decode locator tooltip.\n\nVerification: cargo test -p trx-frontend-http vchan\nVerification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js\n\nCo-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -245,6 +245,7 @@ async function vchanDelete(channelId) {
|
|||||||
async function vchanSubscribe(channelId) {
|
async function vchanSubscribe(channelId) {
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
try {
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
{
|
{
|
||||||
@@ -418,6 +419,7 @@ async function vchanSetChannelFreq(freqHz) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
{
|
{
|
||||||
@@ -435,6 +437,7 @@ async function vchanSetChannelFreq(freqHz) {
|
|||||||
async function vchanSetChannelBandwidth(bwHz) {
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
try {
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
{
|
{
|
||||||
@@ -452,6 +455,7 @@ async function vchanSetChannelBandwidth(bwHz) {
|
|||||||
async function vchanSetChannelMode(mode) {
|
async function vchanSetChannelMode(mode) {
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
try {
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1773,7 +1773,6 @@ body.map-fake-fullscreen-active {
|
|||||||
max-height: min(22rem, 60vh);
|
max-height: min(22rem, 60vh);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
border: 1px solid color-mix(in srgb, var(--accent-yellow) 26%, var(--border-light));
|
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -1256,10 +1256,30 @@ pub async fn subscribe_channel(
|
|||||||
path: web::Path<(String, Uuid)>,
|
path: web::Path<(String, Uuid)>,
|
||||||
body: web::Json<SubscribeBody>,
|
body: web::Json<SubscribeBody>,
|
||||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||||
|
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||||
|
bookmark_store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||||
|
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
|
let body = body.into_inner();
|
||||||
let (rig_id, channel_id) = path.into_inner();
|
let (rig_id, channel_id) = path.into_inner();
|
||||||
match vchan_mgr.subscribe_session(body.session_id, &rig_id, channel_id) {
|
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(),
|
None => HttpResponse::NotFound().finish(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1665,6 +1685,112 @@ async fn send_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_command_to_rig(
|
||||||
|
rig_tx: &mpsc::Sender<RigRequest>,
|
||||||
|
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<RigRequest>,
|
||||||
|
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<RigState>) -> Result<RigSnapshot, actix_web::Error> {
|
async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot, actix_web::Error> {
|
||||||
if let Some(view) = rx.borrow().snapshot() {
|
if let Some(view) = rx.borrow().snapshot() {
|
||||||
return Ok(view);
|
return Ok(view);
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ pub struct ClientChannel {
|
|||||||
pub subscribers: usize,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum VChanClientError {
|
pub enum VChanClientError {
|
||||||
/// Channel cap would be exceeded.
|
/// Channel cap would be exceeded.
|
||||||
@@ -138,7 +147,7 @@ impl ClientChannelManager {
|
|||||||
freq_hz: c.freq_hz,
|
freq_hz: c.freq_hz,
|
||||||
mode: c.mode.clone(),
|
mode: c.mode.clone(),
|
||||||
bandwidth_hz: c.bandwidth_hz,
|
bandwidth_hz: c.bandwidth_hz,
|
||||||
permanent: c.permanent,
|
permanent: c.permanent || c.scheduler_bookmark_id.is_some(),
|
||||||
subscribers: c.session_ids.len(),
|
subscribers: c.session_ids.len(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -194,7 +203,7 @@ impl ClientChannelManager {
|
|||||||
freq_hz: c.freq_hz,
|
freq_hz: c.freq_hz,
|
||||||
mode: c.mode.clone(),
|
mode: c.mode.clone(),
|
||||||
bandwidth_hz: c.bandwidth_hz,
|
bandwidth_hz: c.bandwidth_hz,
|
||||||
permanent: c.permanent,
|
permanent: c.permanent || c.scheduler_bookmark_id.is_some(),
|
||||||
subscribers: c.session_ids.len(),
|
subscribers: c.session_ids.len(),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -289,7 +298,7 @@ impl ClientChannelManager {
|
|||||||
freq_hz: ch.freq_hz,
|
freq_hz: ch.freq_hz,
|
||||||
mode: ch.mode.clone(),
|
mode: ch.mode.clone(),
|
||||||
bandwidth_hz: ch.bandwidth_hz,
|
bandwidth_hz: ch.bandwidth_hz,
|
||||||
permanent: ch.permanent,
|
permanent: ch.permanent || ch.scheduler_bookmark_id.is_some(),
|
||||||
subscribers: ch.session_ids.len(),
|
subscribers: ch.session_ids.len(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,7 +340,10 @@ impl ClientChannelManager {
|
|||||||
}
|
}
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
while idx < channels.len() {
|
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);
|
removed_channel_ids.push(channels[idx].id);
|
||||||
channels.remove(idx);
|
channels.remove(idx);
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -361,7 +373,7 @@ impl ClientChannelManager {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|c| c.id == channel_id)
|
.position(|c| c.id == channel_id)
|
||||||
.ok_or(VChanClientError::NotFound)?;
|
.ok_or(VChanClientError::NotFound)?;
|
||||||
if channels[pos].permanent {
|
if channels[pos].permanent || channels[pos].scheduler_bookmark_id.is_some() {
|
||||||
return Err(VChanClientError::Permanent);
|
return Err(VChanClientError::Permanent);
|
||||||
}
|
}
|
||||||
// Collect evicted sessions to clean up the session map.
|
// Collect evicted sessions to clean up the session map.
|
||||||
@@ -480,6 +492,20 @@ impl ClientChannelManager {
|
|||||||
self.sessions.read().unwrap().get(&session_id).cloned()
|
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<SelectedChannel> {
|
||||||
|
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.
|
/// Reconcile visible scheduler-managed channels for a rig.
|
||||||
///
|
///
|
||||||
/// These channels are user-visible virtual channels sourced from the
|
/// 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].mode, "DIG");
|
||||||
assert_eq!(channels[1].bandwidth_hz, 3_000);
|
assert_eq!(channels[1].bandwidth_hz, 3_000);
|
||||||
assert_eq!(channels[1].subscribers, 0);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user