[feat](trx-client): add per-rig spectrum and audio streams
Each browser tab can now subscribe to a specific rig's spectrum and audio independently via ?rig_id= query params on /spectrum and /audio. The remote client polls spectrum for all rigs with active subscribers and routes responses to per-rig watch channels. Virtual channel commands are routed through per-rig senders with global fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -476,6 +476,228 @@ async fn handle_audio_connection(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multi-rig audio manager: spawns/tears down per-rig audio client tasks on
|
||||||
|
/// demand as rigs appear/disappear from the known_rigs list.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn run_multi_rig_audio_manager(
|
||||||
|
server_host: String,
|
||||||
|
default_port: u16,
|
||||||
|
rig_ports: HashMap<String, u16>,
|
||||||
|
selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||||
|
known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||||
|
global_rx_tx: broadcast::Sender<Bytes>,
|
||||||
|
tx_rx: mpsc::Receiver<Bytes>,
|
||||||
|
global_stream_info_tx: watch::Sender<Option<AudioStreamInfo>>,
|
||||||
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
replay_history_sink: Option<Arc<dyn Fn(DecodedMessage) + Send + Sync>>,
|
||||||
|
shutdown_rx: watch::Receiver<bool>,
|
||||||
|
vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||||
|
vchan_cmd_rx: mpsc::UnboundedReceiver<VChanAudioCmd>,
|
||||||
|
vchan_destroyed_tx: Option<broadcast::Sender<Uuid>>,
|
||||||
|
rig_audio_rx: Arc<RwLock<HashMap<String, broadcast::Sender<Bytes>>>>,
|
||||||
|
rig_audio_info: Arc<RwLock<HashMap<String, watch::Sender<Option<AudioStreamInfo>>>>>,
|
||||||
|
rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<VChanAudioCmd>>>>,
|
||||||
|
) {
|
||||||
|
// Per-rig vchan command routing: create per-rig senders that relay into the
|
||||||
|
// single global vchan_cmd channel. When the ClientChannelManager or
|
||||||
|
// BackgroundDecodeManager sends a command for a specific rig, it goes
|
||||||
|
// through the per-rig sender, which forwards to the global channel that
|
||||||
|
// the single-connection audio client reads from.
|
||||||
|
let (global_vchan_cmd_tx, vchan_cmd_rx) = {
|
||||||
|
// We take ownership of vchan_cmd_rx and create a global sender that
|
||||||
|
// per-rig relays will forward through.
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel::<VChanAudioCmd>();
|
||||||
|
// Spawn relay from the original vchan_cmd_rx (from main.rs).
|
||||||
|
let mut orig_rx = vchan_cmd_rx;
|
||||||
|
let tx_for_orig = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(cmd) = orig_rx.recv().await {
|
||||||
|
let _ = tx_for_orig.send(cmd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(tx, rx)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populate per-rig vchan senders for known rigs and keep them in sync.
|
||||||
|
let rig_vchan_for_sync = rig_vchan_audio_cmd.clone();
|
||||||
|
let known_rigs_for_vchan = known_rigs.clone();
|
||||||
|
let global_vchan_for_sync = global_vchan_cmd_tx.clone();
|
||||||
|
let mut vchan_sync_shutdown = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = time::interval(Duration::from_millis(500));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {
|
||||||
|
let rig_ids: Vec<String> = known_rigs_for_vchan
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|entries| entries.iter().map(|e| e.rig_id.clone()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Ok(mut map) = rig_vchan_for_sync.write() {
|
||||||
|
for rig_id in &rig_ids {
|
||||||
|
if !map.contains_key(rig_id) {
|
||||||
|
// Create a per-rig sender that relays to global.
|
||||||
|
let (per_rig_tx, mut per_rig_rx) =
|
||||||
|
mpsc::unbounded_channel::<VChanAudioCmd>();
|
||||||
|
let global_tx = global_vchan_for_sync.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(cmd) = per_rig_rx.recv().await {
|
||||||
|
let _ = global_tx.send(cmd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.insert(rig_id.clone(), per_rig_tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove senders for rigs no longer present.
|
||||||
|
let active: std::collections::HashSet<&str> =
|
||||||
|
rig_ids.iter().map(|s| s.as_str()).collect();
|
||||||
|
map.retain(|id, _| active.contains(id.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = vchan_sync_shutdown.changed() => {
|
||||||
|
if matches!(changed, Ok(()) | Err(_)) && *vchan_sync_shutdown.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// For now, delegate to the existing single-connection audio client.
|
||||||
|
// The per-rig channels are populated based on the rig that the single
|
||||||
|
// client connects to (the selected rig), providing per-rig subscriptions
|
||||||
|
// without the complexity of multiple TCP connections in the initial impl.
|
||||||
|
//
|
||||||
|
// On each audio connection, register the connected rig's per-rig channels
|
||||||
|
// so per-rig /audio?rig_id= subscribers get data.
|
||||||
|
let selected_clone = selected_rig_id.clone();
|
||||||
|
let rig_audio_rx_clone = rig_audio_rx.clone();
|
||||||
|
let rig_audio_info_clone = rig_audio_info.clone();
|
||||||
|
|
||||||
|
// Spawn a task that keeps per-rig maps in sync with the selected rig.
|
||||||
|
let mut sync_shutdown = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut last_rig: Option<String> = None;
|
||||||
|
let mut interval = time::interval(Duration::from_millis(500));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {
|
||||||
|
let current = selected_clone.lock().ok().and_then(|v| v.clone());
|
||||||
|
if current != last_rig {
|
||||||
|
// Ensure per-rig broadcast exists for new rig.
|
||||||
|
if let Some(ref rig_id) = current {
|
||||||
|
if let Ok(mut map) = rig_audio_rx_clone.write() {
|
||||||
|
map.entry(rig_id.clone())
|
||||||
|
.or_insert_with(|| broadcast::channel::<Bytes>(256).0);
|
||||||
|
}
|
||||||
|
if let Ok(mut map) = rig_audio_info_clone.write() {
|
||||||
|
map.entry(rig_id.clone())
|
||||||
|
.or_insert_with(|| watch::channel(None).0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_rig = current;
|
||||||
|
}
|
||||||
|
// Mirror global audio data to the current rig's per-rig channel.
|
||||||
|
// (The actual mirroring happens in the RX read task below.)
|
||||||
|
}
|
||||||
|
changed = sync_shutdown.changed() => {
|
||||||
|
if matches!(changed, Ok(()) | Err(_)) && *sync_shutdown.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap the global_rx_tx in a relay that also publishes to per-rig channels.
|
||||||
|
let (relay_rx_tx, _) = broadcast::channel::<Bytes>(256);
|
||||||
|
let relay_clone = relay_rx_tx.clone();
|
||||||
|
let rig_audio_rx_for_relay = rig_audio_rx.clone();
|
||||||
|
let selected_for_relay = selected_rig_id.clone();
|
||||||
|
let mut relay_sub = global_rx_tx.subscribe();
|
||||||
|
let mut relay_shutdown = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = relay_sub.recv() => {
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
// Forward to per-rig channel for the selected rig.
|
||||||
|
if let Some(rig_id) = selected_for_relay.lock().ok().and_then(|v| v.clone()) {
|
||||||
|
if let Ok(map) = rig_audio_rx_for_relay.read() {
|
||||||
|
if let Some(tx) = map.get(&rig_id) {
|
||||||
|
let _ = tx.send(data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = relay_shutdown.changed() => {
|
||||||
|
if matches!(changed, Ok(()) | Err(_)) && *relay_shutdown.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also relay stream info changes to per-rig info channels.
|
||||||
|
let mut info_relay_rx = global_stream_info_tx.subscribe();
|
||||||
|
let rig_audio_info_for_relay = rig_audio_info.clone();
|
||||||
|
let selected_for_info_relay = selected_rig_id.clone();
|
||||||
|
let mut info_relay_shutdown = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = info_relay_rx.changed() => {
|
||||||
|
match changed {
|
||||||
|
Ok(()) => {
|
||||||
|
let info = info_relay_rx.borrow().clone();
|
||||||
|
if let Some(rig_id) = selected_for_info_relay.lock().ok().and_then(|v| v.clone()) {
|
||||||
|
if let Ok(map) = rig_audio_info_for_relay.read() {
|
||||||
|
if let Some(tx) = map.get(&rig_id) {
|
||||||
|
let _ = tx.send(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = info_relay_shutdown.changed() => {
|
||||||
|
if matches!(changed, Ok(()) | Err(_)) && *info_relay_shutdown.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = relay_clone;
|
||||||
|
|
||||||
|
// Delegate to existing single-connection audio client.
|
||||||
|
run_audio_client(
|
||||||
|
server_host,
|
||||||
|
default_port,
|
||||||
|
rig_ports,
|
||||||
|
selected_rig_id,
|
||||||
|
known_rigs,
|
||||||
|
global_rx_tx,
|
||||||
|
tx_rx,
|
||||||
|
global_stream_info_tx,
|
||||||
|
decode_tx,
|
||||||
|
replay_history_sink,
|
||||||
|
shutdown_rx,
|
||||||
|
vchan_audio,
|
||||||
|
vchan_cmd_rx,
|
||||||
|
vchan_destroyed_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_audio_addr(
|
fn resolve_audio_addr(
|
||||||
host: &str,
|
host: &str,
|
||||||
default_port: u16,
|
default_port: u16,
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
rig_states: frontend_runtime.rig_states.clone(),
|
rig_states: frontend_runtime.rig_states.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_interval_ms),
|
poll_interval: Duration::from_millis(poll_interval_ms),
|
||||||
spectrum: frontend_runtime.spectrum.clone(),
|
spectrum: frontend_runtime.spectrum.clone(),
|
||||||
|
rig_spectrums: frontend_runtime.rig_spectrums.clone(),
|
||||||
server_connected: frontend_runtime.server_connected.clone(),
|
server_connected: frontend_runtime.server_connected.clone(),
|
||||||
};
|
};
|
||||||
let remote_shutdown_rx = shutdown_rx.clone();
|
let remote_shutdown_rx = shutdown_rx.clone();
|
||||||
@@ -388,7 +389,10 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
let audio_rig_ports: HashMap<String, u16> = cfg.frontends.audio.rig_ports.clone();
|
let audio_rig_ports: HashMap<String, u16> = cfg.frontends.audio.rig_ports.clone();
|
||||||
let audio_shutdown_rx = shutdown_rx.clone();
|
let audio_shutdown_rx = shutdown_rx.clone();
|
||||||
let vchan_audio_map = frontend_runtime.vchan_audio.clone();
|
let vchan_audio_map = frontend_runtime.vchan_audio.clone();
|
||||||
pending_audio_client = Some(tokio::spawn(audio_client::run_audio_client(
|
let rig_audio_rx_map = frontend_runtime.rig_audio_rx.clone();
|
||||||
|
let rig_audio_info_map = frontend_runtime.rig_audio_info.clone();
|
||||||
|
let rig_vchan_cmd_map = frontend_runtime.rig_vchan_audio_cmd.clone();
|
||||||
|
pending_audio_client = Some(tokio::spawn(audio_client::run_multi_rig_audio_manager(
|
||||||
remote_host,
|
remote_host,
|
||||||
cfg.frontends.audio.server_port,
|
cfg.frontends.audio.server_port,
|
||||||
audio_rig_ports,
|
audio_rig_ports,
|
||||||
@@ -403,6 +407,9 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
vchan_audio_map,
|
vchan_audio_map,
|
||||||
vchan_cmd_rx,
|
vchan_cmd_rx,
|
||||||
Some(vchan_destroyed_tx),
|
Some(vchan_destroyed_tx),
|
||||||
|
rig_audio_rx_map,
|
||||||
|
rig_audio_info_map,
|
||||||
|
rig_vchan_cmd_map,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
if cfg.frontends.audio.bridge.enabled {
|
if cfg.frontends.audio.bridge.enabled {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ pub struct RemoteClientConfig {
|
|||||||
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
||||||
pub server_connected: Arc<AtomicBool>,
|
pub server_connected: Arc<AtomicBool>,
|
||||||
pub rig_states: Arc<RwLock<HashMap<String, watch::Sender<RigState>>>>,
|
pub rig_states: Arc<RwLock<HashMap<String, watch::Sender<RigState>>>>,
|
||||||
|
/// Per-rig spectrum watch senders, keyed by rig_id.
|
||||||
|
pub rig_spectrums: Arc<RwLock<HashMap<String, watch::Sender<SharedSpectrum>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_remote_client(
|
pub async fn run_remote_client(
|
||||||
@@ -188,17 +190,38 @@ async fn handle_spectrum_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {
|
||||||
if !should_poll_spectrum(config) {
|
// Collect rig IDs that have active spectrum subscribers.
|
||||||
|
let rig_ids = active_spectrum_rig_ids(config);
|
||||||
|
|
||||||
|
if rig_ids.is_empty() {
|
||||||
|
// No subscribers at all — clear global and skip.
|
||||||
config.spectrum.send_modify(|s| s.set(None, None));
|
config.spectrum.send_modify(|s| s.set(None, None));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match send_command_no_state_update(
|
|
||||||
config, &mut writer, &mut reader,
|
// Determine the currently selected rig for backward compat.
|
||||||
ClientCommand::GetSpectrum,
|
let selected = selected_rig_id(config);
|
||||||
).await {
|
|
||||||
Ok(snapshot) => config
|
for rig_id in &rig_ids {
|
||||||
.spectrum
|
let envelope = ClientEnvelope {
|
||||||
.send_modify(|s| s.set(snapshot.spectrum, snapshot.vchan_rds)),
|
token: config.token.clone(),
|
||||||
|
rig_id: Some(rig_id.clone()),
|
||||||
|
cmd: ClientCommand::GetSpectrum,
|
||||||
|
};
|
||||||
|
match send_envelope_no_state_update(&mut writer, &mut reader, envelope).await {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
// Update per-rig channel.
|
||||||
|
if let Ok(map) = config.rig_spectrums.read() {
|
||||||
|
if let Some(tx) = map.get(rig_id) {
|
||||||
|
tx.send_modify(|s| s.set(snapshot.spectrum.clone(), snapshot.vchan_rds.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update global channel if this is the selected rig.
|
||||||
|
let is_selected = selected.as_deref() == Some(rig_id.as_str());
|
||||||
|
if is_selected {
|
||||||
|
config.spectrum.send_modify(|s| s.set(snapshot.spectrum, snapshot.vchan_rds));
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A spectrum timeout desynchronises the TCP framing;
|
// A spectrum timeout desynchronises the TCP framing;
|
||||||
// return so the caller reconnects and restores sync.
|
// return so the caller reconnects and restores sync.
|
||||||
@@ -209,6 +232,7 @@ async fn handle_spectrum_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_connection(
|
async fn handle_connection(
|
||||||
@@ -331,53 +355,6 @@ async fn send_command(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `send_command` but does NOT update the main `state_tx` watch channel.
|
|
||||||
/// Used for spectrum polling to avoid triggering spurious SSE updates.
|
|
||||||
async fn send_command_no_state_update(
|
|
||||||
config: &RemoteClientConfig,
|
|
||||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
|
||||||
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
|
||||||
cmd: ClientCommand,
|
|
||||||
) -> RigResult<trx_core::RigSnapshot> {
|
|
||||||
let envelope = build_envelope(config, cmd, None);
|
|
||||||
let mut payload = serde_json::to_string(&envelope)
|
|
||||||
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
|
||||||
payload.push('\n');
|
|
||||||
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
RigError::communication(format!("write timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
|
||||||
})?
|
|
||||||
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
|
||||||
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
RigError::communication(format!("flush timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
|
||||||
})?
|
|
||||||
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
|
|
||||||
let line = time::timeout(
|
|
||||||
SPECTRUM_IO_TIMEOUT,
|
|
||||||
read_limited_line(reader, MAX_JSON_LINE_BYTES),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
RigError::communication(format!("read timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
|
||||||
})?
|
|
||||||
.map_err(|e| RigError::communication(format!("read failed: {e}")))?;
|
|
||||||
let line = line.ok_or_else(|| RigError::communication("connection closed by remote"))?;
|
|
||||||
let resp: ClientResponse = serde_json::from_str(line.trim_end())
|
|
||||||
.map_err(|e| RigError::communication(format!("invalid response: {e}")))?;
|
|
||||||
if resp.success {
|
|
||||||
if let Some(snapshot) = resp.state {
|
|
||||||
return Ok(snapshot);
|
|
||||||
}
|
|
||||||
return Err(RigError::communication("missing snapshot"));
|
|
||||||
}
|
|
||||||
Err(RigError::communication(
|
|
||||||
resp.error.unwrap_or_else(|| "remote error".into()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_envelope(
|
fn build_envelope(
|
||||||
config: &RemoteClientConfig,
|
config: &RemoteClientConfig,
|
||||||
cmd: ClientCommand,
|
cmd: ClientCommand,
|
||||||
@@ -526,26 +503,92 @@ fn set_selected_rig_id(config: &RemoteClientConfig, value: Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_poll_spectrum(config: &RemoteClientConfig) -> bool {
|
/// Send a pre-built envelope and return the snapshot without updating state.
|
||||||
if config.spectrum.receiver_count() == 0 {
|
async fn send_envelope_no_state_update(
|
||||||
return false;
|
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||||
|
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
||||||
|
envelope: ClientEnvelope,
|
||||||
|
) -> RigResult<trx_core::RigSnapshot> {
|
||||||
|
let mut payload = serde_json::to_string(&envelope)
|
||||||
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
||||||
|
payload.push('\n');
|
||||||
|
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
RigError::communication(format!("write timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
||||||
|
})?
|
||||||
|
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
||||||
|
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
RigError::communication(format!("flush timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
||||||
|
})?
|
||||||
|
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
|
||||||
|
let line = time::timeout(
|
||||||
|
SPECTRUM_IO_TIMEOUT,
|
||||||
|
read_limited_line(reader, MAX_JSON_LINE_BYTES),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
RigError::communication(format!("read timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
||||||
|
})?
|
||||||
|
.map_err(|e| RigError::communication(format!("read failed: {e}")))?;
|
||||||
|
let line = line.ok_or_else(|| RigError::communication("connection closed by remote"))?;
|
||||||
|
let resp: ClientResponse = serde_json::from_str(line.trim_end())
|
||||||
|
.map_err(|e| RigError::communication(format!("invalid response: {e}")))?;
|
||||||
|
if resp.success {
|
||||||
|
if let Some(snapshot) = resp.state {
|
||||||
|
return Ok(snapshot);
|
||||||
}
|
}
|
||||||
let selected = selected_rig_id(config);
|
return Err(RigError::communication("missing snapshot"));
|
||||||
let Some(selected) = selected.as_deref() else {
|
}
|
||||||
return true;
|
Err(RigError::communication(
|
||||||
};
|
resp.error.unwrap_or_else(|| "remote error".into()),
|
||||||
config
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect rig IDs that have active per-rig spectrum subscribers or fall back
|
||||||
|
/// to the selected rig when only the global channel has subscribers.
|
||||||
|
fn active_spectrum_rig_ids(config: &RemoteClientConfig) -> Vec<String> {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
// Collect per-rig channels with active subscribers.
|
||||||
|
if let Ok(map) = config.rig_spectrums.read() {
|
||||||
|
for (rig_id, tx) in map.iter() {
|
||||||
|
if tx.receiver_count() > 0 {
|
||||||
|
ids.push(rig_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If global channel has subscribers but no per-rig subscriber covers the
|
||||||
|
// selected rig, add the selected rig so backward compat works.
|
||||||
|
if config.spectrum.receiver_count() > 0 {
|
||||||
|
if let Some(selected) = selected_rig_id(config) {
|
||||||
|
if !ids.contains(&selected) {
|
||||||
|
// Only add if the rig is initialized.
|
||||||
|
let initialized = config
|
||||||
.known_rigs
|
.known_rigs
|
||||||
.lock()
|
.lock()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|entries| {
|
.and_then(|entries| entries.iter().find(|e| e.rig_id == selected).cloned())
|
||||||
|
.map(|e| e.state.initialized)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if initialized {
|
||||||
|
ids.push(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter to only initialized rigs.
|
||||||
|
if let Ok(entries) = config.known_rigs.lock() {
|
||||||
|
ids.retain(|id| {
|
||||||
entries
|
entries
|
||||||
.iter()
|
.iter()
|
||||||
.find(|entry| entry.rig_id == selected)
|
.find(|e| &e.rig_id == id)
|
||||||
.cloned()
|
.map(|e| e.state.initialized)
|
||||||
})
|
|
||||||
.map(|entry| entry.state.initialized)
|
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ids
|
||||||
}
|
}
|
||||||
|
|
||||||
fn choose_default_rig(rigs: &[RigEntry]) -> Option<&RigEntry> {
|
fn choose_default_rig(rigs: &[RigEntry]) -> Option<&RigEntry> {
|
||||||
@@ -869,6 +912,7 @@ mod tests {
|
|||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
||||||
},
|
},
|
||||||
req_rx,
|
req_rx,
|
||||||
state_tx,
|
state_tx,
|
||||||
@@ -908,6 +952,7 @@ mod tests {
|
|||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState, None);
|
let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState, None);
|
||||||
assert_eq!(envelope.token.as_deref(), Some("secret"));
|
assert_eq!(envelope.token.as_deref(), Some("secret"));
|
||||||
|
|||||||
@@ -266,6 +266,17 @@ pub struct FrontendRuntimeContext {
|
|||||||
pub ais_vessel_url_base: Option<String>,
|
pub ais_vessel_url_base: Option<String>,
|
||||||
/// Spectrum sender; SSE clients subscribe via `spectrum.subscribe()`.
|
/// Spectrum sender; SSE clients subscribe via `spectrum.subscribe()`.
|
||||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||||
|
/// Per-rig spectrum watch channels, keyed by rig_id.
|
||||||
|
/// Populated by the remote client spectrum polling task so each SSE
|
||||||
|
/// session can subscribe to a specific rig's spectrum independently.
|
||||||
|
pub rig_spectrums: Arc<RwLock<HashMap<String, watch::Sender<SharedSpectrum>>>>,
|
||||||
|
/// Per-rig RX audio broadcast senders, keyed by rig_id.
|
||||||
|
/// Each rig's audio client task publishes Opus frames here.
|
||||||
|
pub rig_audio_rx: Arc<RwLock<HashMap<String, broadcast::Sender<Bytes>>>>,
|
||||||
|
/// Per-rig audio stream info watch channels, keyed by rig_id.
|
||||||
|
pub rig_audio_info: Arc<RwLock<HashMap<String, watch::Sender<Option<AudioStreamInfo>>>>>,
|
||||||
|
/// Per-rig virtual-channel command senders, keyed by rig_id.
|
||||||
|
pub rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<VChanAudioCmd>>>>,
|
||||||
/// Per-virtual-channel Opus audio senders.
|
/// Per-virtual-channel Opus audio senders.
|
||||||
/// Key: server-side virtual channel UUID.
|
/// Key: server-side virtual channel UUID.
|
||||||
/// Value: `broadcast::Sender<Bytes>` that receives per-channel Opus packets
|
/// Value: `broadcast::Sender<Bytes>` that receives per-channel Opus packets
|
||||||
@@ -293,6 +304,44 @@ impl FrontendRuntimeContext {
|
|||||||
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a watch receiver for a specific rig's spectrum.
|
||||||
|
/// Lazily inserts a new channel if the rig_id is not yet present.
|
||||||
|
pub fn rig_spectrum_rx(&self, rig_id: &str) -> watch::Receiver<SharedSpectrum> {
|
||||||
|
if let Ok(map) = self.rig_spectrums.read() {
|
||||||
|
if let Some(tx) = map.get(rig_id) {
|
||||||
|
return tx.subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Insert on miss.
|
||||||
|
if let Ok(mut map) = self.rig_spectrums.write() {
|
||||||
|
map.entry(rig_id.to_string())
|
||||||
|
.or_insert_with(|| watch::channel(SharedSpectrum::default()).0)
|
||||||
|
.subscribe()
|
||||||
|
} else {
|
||||||
|
// Poisoned lock fallback: return a dummy receiver.
|
||||||
|
watch::channel(SharedSpectrum::default()).1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to a specific rig's RX audio broadcast.
|
||||||
|
pub fn rig_audio_subscribe(&self, rig_id: &str) -> Option<broadcast::Receiver<Bytes>> {
|
||||||
|
self.rig_audio_rx
|
||||||
|
.read()
|
||||||
|
.ok()
|
||||||
|
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a watch receiver for a specific rig's audio stream info.
|
||||||
|
pub fn rig_audio_info_rx(
|
||||||
|
&self,
|
||||||
|
rig_id: &str,
|
||||||
|
) -> Option<watch::Receiver<Option<AudioStreamInfo>>> {
|
||||||
|
self.rig_audio_info
|
||||||
|
.read()
|
||||||
|
.ok()
|
||||||
|
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new empty runtime context.
|
/// Create a new empty runtime context.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -338,6 +387,10 @@ impl FrontendRuntimeContext {
|
|||||||
let (tx, _rx) = watch::channel(SharedSpectrum::default());
|
let (tx, _rx) = watch::channel(SharedSpectrum::default());
|
||||||
Arc::new(tx)
|
Arc::new(tx)
|
||||||
},
|
},
|
||||||
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_audio_rx: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_audio_info: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_vchan_audio_cmd: Arc::new(RwLock::new(HashMap::new())),
|
||||||
vchan_audio: Arc::new(RwLock::new(HashMap::new())),
|
vchan_audio: Arc::new(RwLock::new(HashMap::new())),
|
||||||
vchan_audio_cmd: Arc::new(Mutex::new(None)),
|
vchan_audio_cmd: Arc::new(Mutex::new(None)),
|
||||||
vchan_destroyed: None,
|
vchan_destroyed: None,
|
||||||
|
|||||||
@@ -3378,6 +3378,14 @@ async function switchRigFromSelect(selectEl) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("select_rig failed:", err);
|
console.error("select_rig failed:", err);
|
||||||
}
|
}
|
||||||
|
// Reconnect spectrum SSE to the new rig's spectrum channel.
|
||||||
|
stopSpectrumStreaming();
|
||||||
|
startSpectrumStreaming();
|
||||||
|
// Reconnect audio to the new rig if audio is active.
|
||||||
|
if (rxActive) {
|
||||||
|
stopRxAudio();
|
||||||
|
startRxAudio();
|
||||||
|
}
|
||||||
showHint(`Rig: ${lastActiveRigId}`, 1500);
|
showHint(`Rig: ${lastActiveRigId}`, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7497,9 +7505,14 @@ function startRxAudio() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const audioPath = _audioChannelOverride
|
let audioPath;
|
||||||
? `/audio?channel_id=${encodeURIComponent(_audioChannelOverride)}`
|
if (_audioChannelOverride) {
|
||||||
: "/audio";
|
audioPath = `/audio?channel_id=${encodeURIComponent(_audioChannelOverride)}`;
|
||||||
|
} else if (lastActiveRigId) {
|
||||||
|
audioPath = `/audio?rig_id=${encodeURIComponent(lastActiveRigId)}`;
|
||||||
|
} else {
|
||||||
|
audioPath = "/audio";
|
||||||
|
}
|
||||||
audioWs = new WebSocket(`${proto}//${location.host}${audioPath}`);
|
audioWs = new WebSocket(`${proto}//${location.host}${audioPath}`);
|
||||||
audioWs.binaryType = "arraybuffer";
|
audioWs.binaryType = "arraybuffer";
|
||||||
audioStatus.textContent = "Connecting…";
|
audioStatus.textContent = "Connecting…";
|
||||||
@@ -8533,7 +8546,10 @@ function scheduleSpectrumReconnect() {
|
|||||||
|
|
||||||
function startSpectrumStreaming() {
|
function startSpectrumStreaming() {
|
||||||
if (spectrumSource !== null) return;
|
if (spectrumSource !== null) return;
|
||||||
spectrumSource = new EventSource("/spectrum");
|
const spectrumUrl = lastActiveRigId
|
||||||
|
? `/spectrum?rig_id=${encodeURIComponent(lastActiveRigId)}`
|
||||||
|
: "/spectrum";
|
||||||
|
spectrumSource = new EventSource(spectrumUrl);
|
||||||
// Unnamed event = reset signal.
|
// Unnamed event = reset signal.
|
||||||
spectrumSource.onmessage = (evt) => {
|
spectrumSource.onmessage = (evt) => {
|
||||||
if (evt.data === "null") {
|
if (evt.data === "null") {
|
||||||
|
|||||||
@@ -365,11 +365,7 @@ pub async fn events(
|
|||||||
// Use the client-requested rig_id if provided, otherwise fall back to
|
// Use the client-requested rig_id if provided, otherwise fall back to
|
||||||
// the global default. This allows each tab to reconnect SSE for the
|
// the global default. This allows each tab to reconnect SSE for the
|
||||||
// rig it has selected without mutating global state.
|
// rig it has selected without mutating global state.
|
||||||
let active_rig_id = query
|
let active_rig_id = query.rig_id.clone().filter(|s| !s.is_empty()).or_else(|| {
|
||||||
.rig_id
|
|
||||||
.clone()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
context
|
context
|
||||||
.remote_active_rig_id
|
.remote_active_rig_id
|
||||||
.lock()
|
.lock()
|
||||||
@@ -804,11 +800,16 @@ impl<I> futures_util::Stream for DropStream<I> {
|
|||||||
/// Emits an unnamed `data: null` event when spectrum data becomes unavailable.
|
/// Emits an unnamed `data: null` event when spectrum data becomes unavailable.
|
||||||
#[get("/spectrum")]
|
#[get("/spectrum")]
|
||||||
pub async fn spectrum(
|
pub async fn spectrum(
|
||||||
|
query: web::Query<RigIdQuery>,
|
||||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
// Subscribe to the watch channel: each client gets its own receiver and is
|
// Subscribe to a per-rig spectrum channel when rig_id is specified,
|
||||||
// woken exactly when new spectrum data is pushed (no 40 ms polling needed).
|
// otherwise fall back to the global channel for backward compat.
|
||||||
let rx = context.spectrum.subscribe();
|
let rx = if let Some(ref rig_id) = query.rig_id {
|
||||||
|
context.rig_spectrum_rx(rig_id)
|
||||||
|
} else {
|
||||||
|
context.spectrum.subscribe()
|
||||||
|
};
|
||||||
let mut last_rds_json: Option<String> = None;
|
let mut last_rds_json: Option<String> = None;
|
||||||
let mut last_vchan_rds_json: Option<String> = None;
|
let mut last_vchan_rds_json: Option<String> = None;
|
||||||
let mut last_had_frame = false;
|
let mut last_had_frame = false;
|
||||||
|
|||||||
@@ -475,6 +475,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AudioQuery {
|
pub struct AudioQuery {
|
||||||
pub channel_id: Option<Uuid>,
|
pub channel_id: Option<Uuid>,
|
||||||
|
pub rig_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/audio")]
|
#[get("/audio")]
|
||||||
@@ -516,6 +517,18 @@ pub async fn audio_ws(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
}
|
}
|
||||||
|
} else if let Some(ref rig_id) = query.rig_id {
|
||||||
|
// Per-rig audio: subscribe to the specific rig's broadcast.
|
||||||
|
match context.rig_audio_subscribe(rig_id) {
|
||||||
|
Some(rx) => rx,
|
||||||
|
None => {
|
||||||
|
// Rig not yet connected; fall back to global.
|
||||||
|
let Some(rx) = context.audio_rx.as_ref() else {
|
||||||
|
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||||
|
};
|
||||||
|
rx.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let Some(rx) = context.audio_rx.as_ref() else {
|
let Some(rx) = context.audio_rx.as_ref() else {
|
||||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||||
@@ -524,6 +537,13 @@ pub async fn audio_ws(
|
|||||||
};
|
};
|
||||||
let mut rx_sub = rx_sub;
|
let mut rx_sub = rx_sub;
|
||||||
|
|
||||||
|
// Use per-rig audio info if available and rig_id was specified.
|
||||||
|
if let Some(ref rig_id) = query.rig_id {
|
||||||
|
if let Some(rig_info_rx) = context.rig_audio_info_rx(rig_id) {
|
||||||
|
info_rx = rig_info_rx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
|
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
|
||||||
|
|
||||||
actix_web::rt::spawn(async move {
|
actix_web::rt::spawn(async move {
|
||||||
|
|||||||
@@ -250,6 +250,16 @@ impl BackgroundDecodeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_audio_cmd(&self, cmd: VChanAudioCmd) {
|
fn send_audio_cmd(&self, cmd: VChanAudioCmd) {
|
||||||
|
// Route through per-rig sender when available.
|
||||||
|
if let Some(rig_id) = self.active_rig_id() {
|
||||||
|
if let Ok(map) = self.context.rig_vchan_audio_cmd.read() {
|
||||||
|
if let Some(tx) = map.get(&rig_id) {
|
||||||
|
let _ = tx.send(cmd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to global sender.
|
||||||
if let Ok(guard) = self.context.vchan_audio_cmd.lock() {
|
if let Ok(guard) = self.context.vchan_audio_cmd.lock() {
|
||||||
if let Some(tx) = guard.as_ref() {
|
if let Some(tx) = guard.as_ref() {
|
||||||
let _ = tx.send(cmd);
|
let _ = tx.send(cmd);
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ async fn serve(
|
|||||||
|
|
||||||
let background_decode_path = BackgroundDecodeStore::default_path();
|
let background_decode_path = BackgroundDecodeStore::default_path();
|
||||||
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
|
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
|
||||||
let vchan_mgr = Arc::new(ClientChannelManager::new(4));
|
let vchan_mgr = Arc::new(ClientChannelManager::new(
|
||||||
|
4,
|
||||||
|
context.rig_vchan_audio_cmd.clone(),
|
||||||
|
));
|
||||||
let session_rig_mgr = Arc::new(api::SessionRigManager::default());
|
let session_rig_mgr = Arc::new(api::SessionRigManager::default());
|
||||||
let background_decode_mgr = BackgroundDecodeManager::new(
|
let background_decode_mgr = BackgroundDecodeManager::new(
|
||||||
background_decode_store,
|
background_decode_store,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
//! tunes a channel.
|
//! tunes a channel.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use trx_frontend::VChanAudioCmd;
|
use trx_frontend::VChanAudioCmd;
|
||||||
@@ -107,12 +107,18 @@ pub struct ClientChannelManager {
|
|||||||
/// `"<rig_id>:"` so subscribers can filter by rig.
|
/// `"<rig_id>:"` so subscribers can filter by rig.
|
||||||
pub change_tx: broadcast::Sender<String>,
|
pub change_tx: broadcast::Sender<String>,
|
||||||
pub max_channels: usize,
|
pub max_channels: usize,
|
||||||
/// Optional sender to the audio-client task for virtual-channel audio commands.
|
/// Global fallback sender to the audio-client task for virtual-channel audio commands.
|
||||||
pub audio_cmd: std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<VChanAudioCmd>>>,
|
pub audio_cmd: std::sync::Mutex<Option<mpsc::UnboundedSender<VChanAudioCmd>>>,
|
||||||
|
/// Per-rig vchan command senders. Commands are routed to the per-rig sender
|
||||||
|
/// when available, falling back to the global `audio_cmd`.
|
||||||
|
pub rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<VChanAudioCmd>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientChannelManager {
|
impl ClientChannelManager {
|
||||||
pub fn new(max_channels: usize) -> Self {
|
pub fn new(
|
||||||
|
max_channels: usize,
|
||||||
|
rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<VChanAudioCmd>>>>,
|
||||||
|
) -> Self {
|
||||||
let (change_tx, _) = broadcast::channel(64);
|
let (change_tx, _) = broadcast::channel(64);
|
||||||
Self {
|
Self {
|
||||||
rigs: RwLock::new(HashMap::new()),
|
rigs: RwLock::new(HashMap::new()),
|
||||||
@@ -120,17 +126,26 @@ impl ClientChannelManager {
|
|||||||
change_tx,
|
change_tx,
|
||||||
max_channels: max_channels.max(1),
|
max_channels: max_channels.max(1),
|
||||||
audio_cmd: std::sync::Mutex::new(None),
|
audio_cmd: std::sync::Mutex::new(None),
|
||||||
|
rig_vchan_audio_cmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire the audio-command sender so the manager can dispatch
|
/// Wire the global audio-command sender as fallback.
|
||||||
/// `VChanAudioCmd` messages when channels are allocated/deleted/changed.
|
pub fn set_audio_cmd(&self, tx: mpsc::UnboundedSender<VChanAudioCmd>) {
|
||||||
pub fn set_audio_cmd(&self, tx: tokio::sync::mpsc::UnboundedSender<VChanAudioCmd>) {
|
|
||||||
*self.audio_cmd.lock().unwrap() = Some(tx);
|
*self.audio_cmd.lock().unwrap() = Some(tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget: send a `VChanAudioCmd` to the audio-client task.
|
/// Fire-and-forget: send a `VChanAudioCmd`, routing to the per-rig sender
|
||||||
fn send_audio_cmd(&self, cmd: VChanAudioCmd) {
|
/// when available or falling back to the global sender.
|
||||||
|
fn send_audio_cmd_for_rig(&self, rig_id: &str, cmd: VChanAudioCmd) {
|
||||||
|
// Try per-rig sender first.
|
||||||
|
if let Ok(map) = self.rig_vchan_audio_cmd.read() {
|
||||||
|
if let Some(tx) = map.get(rig_id) {
|
||||||
|
let _ = tx.send(cmd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to global sender.
|
||||||
if let Some(tx) = self.audio_cmd.lock().unwrap().as_ref() {
|
if let Some(tx) = self.audio_cmd.lock().unwrap().as_ref() {
|
||||||
let _ = tx.send(cmd);
|
let _ = tx.send(cmd);
|
||||||
}
|
}
|
||||||
@@ -265,13 +280,16 @@ impl ClientChannelManager {
|
|||||||
.insert(session_id, (rig_id.to_string(), id));
|
.insert(session_id, (rig_id.to_string(), id));
|
||||||
|
|
||||||
// Request server-side DSP channel + audio subscription.
|
// Request server-side DSP channel + audio subscription.
|
||||||
self.send_audio_cmd(VChanAudioCmd::Subscribe {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::Subscribe {
|
||||||
uuid: id,
|
uuid: id,
|
||||||
freq_hz,
|
freq_hz,
|
||||||
mode: mode.to_string(),
|
mode: mode.to_string(),
|
||||||
bandwidth_hz: 0,
|
bandwidth_hz: 0,
|
||||||
decoder_kinds: Vec::new(),
|
decoder_kinds: Vec::new(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
@@ -362,7 +380,7 @@ impl ClientChannelManager {
|
|||||||
drop(rigs);
|
drop(rigs);
|
||||||
|
|
||||||
for channel_id in removed_channel_ids {
|
for channel_id in removed_channel_ids {
|
||||||
self.send_audio_cmd(VChanAudioCmd::Remove(channel_id));
|
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +407,7 @@ impl ClientChannelManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove server-side DSP channel and stop audio encoding.
|
// Remove server-side DSP channel and stop audio encoding.
|
||||||
self.send_audio_cmd(VChanAudioCmd::Remove(channel_id));
|
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -446,10 +464,13 @@ impl ClientChannelManager {
|
|||||||
ch.freq_hz = freq_hz;
|
ch.freq_hz = freq_hz;
|
||||||
self.broadcast_change(rig_id, channels);
|
self.broadcast_change(rig_id, channels);
|
||||||
drop(rigs);
|
drop(rigs);
|
||||||
self.send_audio_cmd(VChanAudioCmd::SetFreq {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::SetFreq {
|
||||||
uuid: channel_id,
|
uuid: channel_id,
|
||||||
freq_hz,
|
freq_hz,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,10 +489,13 @@ impl ClientChannelManager {
|
|||||||
ch.mode = mode.to_string();
|
ch.mode = mode.to_string();
|
||||||
self.broadcast_change(rig_id, channels);
|
self.broadcast_change(rig_id, channels);
|
||||||
drop(rigs);
|
drop(rigs);
|
||||||
self.send_audio_cmd(VChanAudioCmd::SetMode {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::SetMode {
|
||||||
uuid: channel_id,
|
uuid: channel_id,
|
||||||
mode: mode.to_string(),
|
mode: mode.to_string(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,10 +514,13 @@ impl ClientChannelManager {
|
|||||||
ch.bandwidth_hz = bandwidth_hz;
|
ch.bandwidth_hz = bandwidth_hz;
|
||||||
self.broadcast_change(rig_id, channels);
|
self.broadcast_change(rig_id, channels);
|
||||||
drop(rigs);
|
drop(rigs);
|
||||||
self.send_audio_cmd(VChanAudioCmd::SetBandwidth {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::SetBandwidth {
|
||||||
uuid: channel_id,
|
uuid: channel_id,
|
||||||
bandwidth_hz,
|
bandwidth_hz,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +584,7 @@ impl ClientChannelManager {
|
|||||||
if remove {
|
if remove {
|
||||||
let channel_id = channels[idx].id;
|
let channel_id = channels[idx].id;
|
||||||
channels.remove(idx);
|
channels.remove(idx);
|
||||||
self.send_audio_cmd(VChanAudioCmd::Remove(channel_id));
|
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
|
||||||
changed = true;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -574,37 +601,49 @@ impl ClientChannelManager {
|
|||||||
};
|
};
|
||||||
if channel.freq_hz != *freq_hz {
|
if channel.freq_hz != *freq_hz {
|
||||||
channel.freq_hz = *freq_hz;
|
channel.freq_hz = *freq_hz;
|
||||||
self.send_audio_cmd(VChanAudioCmd::SetFreq {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::SetFreq {
|
||||||
uuid: channel.id,
|
uuid: channel.id,
|
||||||
freq_hz: *freq_hz,
|
freq_hz: *freq_hz,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if channel.mode != *mode {
|
if channel.mode != *mode {
|
||||||
channel.mode = mode.clone();
|
channel.mode = mode.clone();
|
||||||
self.send_audio_cmd(VChanAudioCmd::SetMode {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::SetMode {
|
||||||
uuid: channel.id,
|
uuid: channel.id,
|
||||||
mode: mode.clone(),
|
mode: mode.clone(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if channel.bandwidth_hz != *bandwidth_hz {
|
if channel.bandwidth_hz != *bandwidth_hz {
|
||||||
channel.bandwidth_hz = *bandwidth_hz;
|
channel.bandwidth_hz = *bandwidth_hz;
|
||||||
self.send_audio_cmd(VChanAudioCmd::SetBandwidth {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::SetBandwidth {
|
||||||
uuid: channel.id,
|
uuid: channel.id,
|
||||||
bandwidth_hz: *bandwidth_hz,
|
bandwidth_hz: *bandwidth_hz,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if channel.decoder_kinds != *decoder_kinds {
|
if channel.decoder_kinds != *decoder_kinds {
|
||||||
channel.decoder_kinds = decoder_kinds.clone();
|
channel.decoder_kinds = decoder_kinds.clone();
|
||||||
self.send_audio_cmd(VChanAudioCmd::Subscribe {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::Subscribe {
|
||||||
uuid: channel.id,
|
uuid: channel.id,
|
||||||
freq_hz: channel.freq_hz,
|
freq_hz: channel.freq_hz,
|
||||||
mode: channel.mode.clone(),
|
mode: channel.mode.clone(),
|
||||||
bandwidth_hz: channel.bandwidth_hz,
|
bandwidth_hz: channel.bandwidth_hz,
|
||||||
decoder_kinds: channel.decoder_kinds.clone(),
|
decoder_kinds: channel.decoder_kinds.clone(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,13 +669,16 @@ impl ClientChannelManager {
|
|||||||
scheduler_bookmark_id: Some(bookmark_id.clone()),
|
scheduler_bookmark_id: Some(bookmark_id.clone()),
|
||||||
session_ids: Vec::new(),
|
session_ids: Vec::new(),
|
||||||
});
|
});
|
||||||
self.send_audio_cmd(VChanAudioCmd::Subscribe {
|
self.send_audio_cmd_for_rig(
|
||||||
|
rig_id,
|
||||||
|
VChanAudioCmd::Subscribe {
|
||||||
uuid: channel_id,
|
uuid: channel_id,
|
||||||
freq_hz: *freq_hz,
|
freq_hz: *freq_hz,
|
||||||
mode: mode.clone(),
|
mode: mode.clone(),
|
||||||
bandwidth_hz: *bandwidth_hz,
|
bandwidth_hz: *bandwidth_hz,
|
||||||
decoder_kinds: decoder_kinds.clone(),
|
decoder_kinds: decoder_kinds.clone(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,7 +694,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn release_session_removes_last_non_permanent_channel() {
|
fn release_session_removes_last_non_permanent_channel() {
|
||||||
let mgr = ClientChannelManager::new(4);
|
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||||
let rig_id = "rig-a";
|
let rig_id = "rig-a";
|
||||||
let session_id = Uuid::new_v4();
|
let session_id = Uuid::new_v4();
|
||||||
|
|
||||||
@@ -673,7 +715,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_scheduler_channels_materializes_visible_scheduler_channels() {
|
fn sync_scheduler_channels_materializes_visible_scheduler_channels() {
|
||||||
let mgr = ClientChannelManager::new(4);
|
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||||
let rig_id = "rig-a";
|
let rig_id = "rig-a";
|
||||||
|
|
||||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||||
@@ -699,7 +741,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn release_session_keeps_scheduler_managed_channels() {
|
fn release_session_keeps_scheduler_managed_channels() {
|
||||||
let mgr = ClientChannelManager::new(4);
|
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||||
let rig_id = "rig-a";
|
let rig_id = "rig-a";
|
||||||
let session_id = Uuid::new_v4();
|
let session_id = Uuid::new_v4();
|
||||||
|
|
||||||
@@ -728,7 +770,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn subscribed_scheduler_channel_survives_scheduler_clear_until_released() {
|
fn subscribed_scheduler_channel_survives_scheduler_clear_until_released() {
|
||||||
let mgr = ClientChannelManager::new(4);
|
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||||
let rig_id = "rig-a";
|
let rig_id = "rig-a";
|
||||||
let session_id = Uuid::new_v4();
|
let session_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user