[fix](trx-frontend-http): show scheduler channels on connect

Materialize scheduler-managed virtual channels before the\ninitial channels SSE event when the scheduler currently\ncontrols the rig.\n\nVerification: cargo test -p trx-frontend-http vchan\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-14 13:30:48 +01:00
parent c50f716390
commit 9e8003afb6
2 changed files with 165 additions and 0 deletions
@@ -223,6 +223,8 @@ pub async fn events(
clients: web::Data<Arc<AtomicUsize>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
bookmark_store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
scheduler_status: web::Data<crate::server::scheduler::SchedulerStatusMap>,
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
) -> Result<HttpResponse, Error> {
let rx = state.get_ref().clone();
@@ -248,6 +250,31 @@ pub async fn events(
initial.status.freq.hz,
&format!("{:?}", initial.status.mode),
);
if scheduler_control.scheduler_allowed() {
let desired = {
let map = scheduler_status.read().unwrap_or_else(|e| e.into_inner());
map.get(rid)
.filter(|status| status.active)
.map(|status| {
status
.last_bookmark_ids
.iter()
.filter_map(|bookmark_id| {
bookmark_store.get(bookmark_id).map(|bookmark| {
(
bookmark_id.clone(),
bookmark.freq_hz,
bookmark.mode,
bookmark.bandwidth_hz.unwrap_or(0) as u32,
)
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
vchan_mgr.sync_scheduler_channels(rid, &desired);
}
}
// Build the prefix burst: rig state → session UUID → initial channels.
@@ -77,6 +77,7 @@ struct InternalChannel {
/// Audio filter bandwidth in Hz (0 = mode default).
bandwidth_hz: u32,
permanent: bool,
scheduler_bookmark_id: Option<String>,
/// Session UUIDs currently subscribed to this channel.
session_ids: Vec<Uuid>,
}
@@ -160,6 +161,7 @@ impl ClientChannelManager {
mode: mode.to_string(),
bandwidth_hz: 0,
permanent: true,
scheduler_bookmark_id: None,
session_ids: Vec::new(),
});
}
@@ -227,6 +229,7 @@ impl ClientChannelManager {
mode: mode.to_string(),
bandwidth_hz: 0,
permanent: false,
scheduler_bookmark_id: None,
session_ids: vec![session_id],
});
@@ -476,6 +479,121 @@ impl ClientChannelManager {
pub fn session_channel(&self, session_id: Uuid) -> Option<(String, Uuid)> {
self.sessions.read().unwrap().get(&session_id).cloned()
}
/// Reconcile visible scheduler-managed channels for a rig.
///
/// These channels are user-visible virtual channels sourced from the
/// scheduler's currently active extra bookmarks. They are kept separate
/// from user-allocated channels so connect-time sync can materialise them
/// without duplicating arbitrary user state.
pub fn sync_scheduler_channels(
&self,
rig_id: &str,
desired: &[(String, u64, String, u32)],
) {
let mut rigs = self.rigs.write().unwrap();
let Some(channels) = rigs.get_mut(rig_id) else {
return;
};
let mut changed = false;
let desired_map: HashMap<String, (u64, String, u32)> = desired
.iter()
.map(|(bookmark_id, freq_hz, mode, bandwidth_hz)| {
(bookmark_id.clone(), (*freq_hz, mode.clone(), *bandwidth_hz))
})
.collect();
let desired_ids: std::collections::HashSet<&str> =
desired_map.keys().map(String::as_str).collect();
let mut idx = 0;
while idx < channels.len() {
let remove = if let Some(bookmark_id) = channels[idx].scheduler_bookmark_id.as_deref() {
!desired_ids.contains(bookmark_id) && channels[idx].session_ids.is_empty()
} else {
false
};
if remove {
let channel_id = channels[idx].id;
channels.remove(idx);
self.send_audio_cmd(VChanAudioCmd::Remove(channel_id));
changed = true;
continue;
}
idx += 1;
}
for channel in channels.iter_mut() {
let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else {
continue;
};
let Some((freq_hz, mode, bandwidth_hz)) = desired_map.get(bookmark_id) else {
continue;
};
if channel.freq_hz != *freq_hz {
channel.freq_hz = *freq_hz;
self.send_audio_cmd(VChanAudioCmd::SetFreq {
uuid: channel.id,
freq_hz: *freq_hz,
});
changed = true;
}
if channel.mode != *mode {
channel.mode = mode.clone();
self.send_audio_cmd(VChanAudioCmd::SetMode {
uuid: channel.id,
mode: mode.clone(),
});
changed = true;
}
if channel.bandwidth_hz != *bandwidth_hz {
channel.bandwidth_hz = *bandwidth_hz;
self.send_audio_cmd(VChanAudioCmd::SetBandwidth {
uuid: channel.id,
bandwidth_hz: *bandwidth_hz,
});
changed = true;
}
}
for (bookmark_id, freq_hz, mode, bandwidth_hz) in desired {
let exists = channels.iter().any(|channel| {
channel.scheduler_bookmark_id.as_deref() == Some(bookmark_id.as_str())
});
if exists {
continue;
}
if channels.len() >= self.max_channels {
break;
}
let channel_id = Uuid::new_v4();
channels.push(InternalChannel {
id: channel_id,
freq_hz: *freq_hz,
mode: mode.clone(),
bandwidth_hz: *bandwidth_hz,
permanent: false,
scheduler_bookmark_id: Some(bookmark_id.clone()),
session_ids: Vec::new(),
});
self.send_audio_cmd(VChanAudioCmd::Subscribe {
uuid: channel_id,
freq_hz: *freq_hz,
mode: mode.clone(),
});
if *bandwidth_hz > 0 {
self.send_audio_cmd(VChanAudioCmd::SetBandwidth {
uuid: channel_id,
bandwidth_hz: *bandwidth_hz,
});
}
changed = true;
}
if changed {
self.broadcast_change(rig_id, channels);
}
}
}
#[cfg(test)]
@@ -502,4 +620,24 @@ mod tests {
assert!(channels.iter().all(|ch| ch.id != channel.id));
assert!(mgr.session_channel(session_id).is_none());
}
#[test]
fn sync_scheduler_channels_materializes_visible_scheduler_channels() {
let mgr = ClientChannelManager::new(4);
let rig_id = "rig-a";
mgr.init_rig(rig_id, 14_074_000, "USB");
mgr.sync_scheduler_channels(
rig_id,
&[("bm-ft8".to_string(), 14_074_000, "DIG".to_string(), 3_000)],
);
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 2);
assert_eq!(channels[1].freq_hz, 14_074_000);
assert_eq!(channels[1].mode, "DIG");
assert_eq!(channels[1].bandwidth_hz, 3_000);
assert_eq!(channels[1].subscribers, 0);
assert!(!channels[1].permanent);
}
}