[feat](trx-client): support audio URL overrides

Use full audio endpoint URLs for trx-server audio connections while
preserving server-advertised ports and legacy port-based fallback for
backward compatibility.

Add `server_url` and per-remote `rig_urls` config entries, plus
validation and tests for audio URL parsing and address resolution.

Co-authored-by: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-23 22:04:14 +01:00
parent d8444f35f6
commit e09f14d2d3
4 changed files with 317 additions and 104 deletions
+118 -23
View File
@@ -21,6 +21,7 @@ use trx_frontend::RemoteRigEntry;
use uuid::Uuid;
use crate::remote_client::RemoteEndpoint;
use trx_core::audio::{
parse_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg,
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
@@ -43,11 +44,36 @@ struct ActiveVChanSub {
decoder_kinds: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AudioConnectConfig {
pub server_host: String,
pub default_port: u16,
pub fixed_addr: Option<String>,
}
impl AudioConnectConfig {
pub fn from_host_port(server_host: String, default_port: u16) -> Self {
Self {
server_host,
default_port,
fixed_addr: None,
}
}
pub fn fixed(addr: String) -> Self {
Self {
server_host: String::new(),
default_port: 0,
fixed_addr: Some(addr),
}
}
}
/// Per-rig audio task state, tracked by the multi-rig manager.
struct PerRigAudioTask {
handle: tokio::task::JoinHandle<()>,
shutdown_tx: watch::Sender<bool>,
port: u16,
addr: String,
}
/// Multi-rig audio manager: spawns/tears down per-rig audio client tasks on
@@ -55,11 +81,8 @@ struct PerRigAudioTask {
/// an `audio_port` gets its own TCP connection.
#[allow(clippy::too_many_arguments)]
pub async fn run_multi_rig_audio_manager(
server_host: String,
default_port: u16,
rig_ports: HashMap<String, u16>,
// Per-rig server host overrides (short_name -> host) for multi-server mode.
rig_server_hosts: HashMap<String, String>,
default_connect: AudioConnectConfig,
rig_connect: HashMap<String, AudioConnectConfig>,
selected_rig_id: Arc<Mutex<Option<String>>>,
known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
global_rx_tx: broadcast::Sender<Bytes>,
@@ -86,28 +109,31 @@ pub async fn run_multi_rig_audio_manager(
loop {
tokio::select! {
_ = poll_interval.tick() => {
// Collect current known rigs and their audio ports.
let current_rigs: HashMap<String, u16> = known_rigs
// Collect current known rigs and their audio endpoints.
let current_rigs: HashMap<String, String> = known_rigs
.lock()
.ok()
.map(|entries| {
entries.iter().map(|e| {
let port = rig_ports.get(&e.rig_id).copied()
.or(e.audio_port)
.unwrap_or(default_port);
(e.rig_id.clone(), port)
let addr = resolve_audio_addr(
&e.rig_id,
e.audio_port,
&rig_connect,
&default_connect,
);
(e.rig_id.clone(), addr)
}).collect()
})
.unwrap_or_default();
// Tear down tasks for rigs that are no longer present or
// whose port has changed.
// whose audio endpoint has changed.
let to_remove: Vec<String> = active_tasks.keys()
.filter(|id| {
match current_rigs.get(*id) {
None => true,
Some(port) => active_tasks.get(*id)
.is_none_or(|t| t.port != *port),
Some(addr) => active_tasks.get(*id)
.is_none_or(|t| t.addr != *addr),
}
})
.cloned()
@@ -121,7 +147,7 @@ pub async fn run_multi_rig_audio_manager(
}
// Spawn tasks for new rigs.
for (rig_id, port) in &current_rigs {
for (rig_id, addr) in &current_rigs {
if active_tasks.contains_key(rig_id) {
continue;
}
@@ -149,10 +175,6 @@ pub async fn run_multi_rig_audio_manager(
map.insert(rig_id.clone(), per_rig_vchan_tx);
}
let host = rig_server_hosts
.get(rig_id)
.unwrap_or(&server_host);
let addr = format!("{}:{}", host, port);
let rig_id_clone = rig_id.clone();
let global_rx_tx_clone = global_rx_tx.clone();
let global_info_tx_clone = global_stream_info_tx.clone();
@@ -162,10 +184,12 @@ pub async fn run_multi_rig_audio_manager(
let vchan_audio_clone = vchan_audio.clone();
let vchan_destroyed_clone = vchan_destroyed_tx.clone();
let tx_rx_clone = tx_rx.clone();
let addr = addr.clone();
let task_addr = addr.clone();
let handle = tokio::spawn(async move {
run_single_rig_audio_client(
addr,
task_addr,
rig_id_clone,
selected_clone,
per_rig_rx_tx,
@@ -183,11 +207,11 @@ pub async fn run_multi_rig_audio_manager(
.await;
});
info!("Audio client: started task for rig {} ({}:{})", rig_id, host, port);
info!("Audio client: started task for rig {} ({})", rig_id, addr);
active_tasks.insert(rig_id.clone(), PerRigAudioTask {
handle,
shutdown_tx: per_rig_shutdown_tx,
port: *port,
addr: addr.clone(),
});
}
}
@@ -206,6 +230,24 @@ pub async fn run_multi_rig_audio_manager(
}
}
fn resolve_audio_addr(
rig_id: &str,
advertised_port: Option<u16>,
rig_connect: &HashMap<String, AudioConnectConfig>,
default_connect: &AudioConnectConfig,
) -> String {
let connect = rig_connect.get(rig_id).unwrap_or(default_connect);
if let Some(addr) = &connect.fixed_addr {
return addr.clone();
}
RemoteEndpoint {
host: connect.server_host.clone(),
port: advertised_port.unwrap_or(connect.default_port),
}
.connect_addr()
}
/// Audio client for a single rig. Maintains its own TCP connection with
/// auto-reconnect, publishes RX frames to both per-rig and (if selected)
/// global broadcast channels.
@@ -295,6 +337,59 @@ async fn run_single_rig_audio_client(
}
}
#[cfg(test)]
mod tests {
use super::{resolve_audio_addr, AudioConnectConfig};
use std::collections::HashMap;
#[test]
fn resolve_audio_addr_prefers_fixed_url() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4531),
&rig_connect,
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
assert_eq!(addr, "audio.example.com:4700");
}
#[test]
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
let mut rig_connect = HashMap::new();
rig_connect.insert(
"home-hf".to_string(),
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
);
let addr = resolve_audio_addr(
"home-hf",
Some(4600),
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "control.example.com:4600");
}
#[test]
fn resolve_audio_addr_falls_back_to_default_port() {
let rig_connect = HashMap::new();
let addr = resolve_audio_addr(
"home-hf",
None,
&rig_connect,
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
);
assert_eq!(addr, "fallback.example.com:4531");
}
}
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
/// but publishes to per-rig channels directly and mirrors to global when selected.
#[allow(clippy::too_many_arguments)]
+81 -11
View File
@@ -143,9 +143,18 @@ pub struct FrontendsConfig {
pub struct AudioClientConfig {
/// Whether audio streaming is enabled
pub enabled: bool,
/// Audio TCP port on the remote server
/// Optional exact audio TCP URL override applied to all remotes.
/// When set, this takes precedence over `server_port`.
pub server_url: Option<String>,
/// Optional per-rig audio URL overrides keyed by remote short name.
/// These take precedence over `server_url`, server-advertised ports, and
/// the legacy `rig_ports` map.
pub rig_urls: HashMap<String, String>,
/// Legacy audio TCP port fallback on the remote server when no URL override
/// is configured and the server does not advertise a per-rig audio port.
pub server_port: u16,
/// Optional per-rig audio port overrides for multi-rig servers.
/// Legacy per-rig audio port overrides for multi-rig servers.
/// Prefer `rig_urls` when the audio endpoint differs by host as well.
pub rig_ports: HashMap<String, u16>,
/// Local audio bridge (virtual device integration)
pub bridge: AudioBridgeConfig,
@@ -155,6 +164,8 @@ impl Default for AudioClientConfig {
fn default() -> Self {
Self {
enabled: true,
server_url: None,
rig_urls: HashMap::new(),
server_port: 4531,
rig_ports: HashMap::new(),
bridge: AudioBridgeConfig::default(),
@@ -406,10 +417,7 @@ impl ClientConfig {
return Err(format!("[[remotes]][{}].name must not be empty", i));
}
if !seen_names.insert(&entry.name) {
return Err(format!(
"[[remotes]] duplicate name \"{}\"",
entry.name
));
return Err(format!("[[remotes]] duplicate name \"{}\"", entry.name));
}
if entry.url.trim().is_empty() {
return Err(format!(
@@ -483,7 +491,7 @@ impl ClientConfig {
if let Some(rig_id) = &self.frontends.http.default_rig_name {
if rig_id.trim().is_empty() {
return Err(
"[frontends.http].default_rig_name must not be empty when set".to_string()
"[frontends.http].default_rig_name must not be empty when set".to_string(),
);
}
}
@@ -534,9 +542,23 @@ impl ClientConfig {
));
}
}
if self.frontends.audio.enabled && self.frontends.audio.server_port == 0 {
if let Some(url) = &self.frontends.audio.server_url {
crate::remote_client::parse_audio_url(url)
.map_err(|e| format!("[frontends.audio].server_url {e}"))?;
}
if self.frontends.audio.enabled
&& self.frontends.audio.server_url.is_none()
&& self.frontends.audio.server_port == 0
{
return Err("[frontends.audio].server_port must be > 0 when enabled".to_string());
}
for (rig_id, url) in &self.frontends.audio.rig_urls {
if rig_id.trim().is_empty() {
return Err("[frontends.audio].rig_urls keys must not be empty".to_string());
}
crate::remote_client::parse_audio_url(url)
.map_err(|e| format!("[frontends.audio].rig_urls[\"{rig_id}\"] {e}"))?;
}
for (rig_id, port) in &self.frontends.audio.rig_ports {
if rig_id.trim().is_empty() {
return Err("[frontends.audio].rig_ports keys must not be empty".to_string());
@@ -750,6 +772,8 @@ mod tests {
);
assert_eq!(config.remote.poll_interval_ms, 750);
assert!(config.frontends.audio.enabled);
assert!(config.frontends.audio.server_url.is_none());
assert!(config.frontends.audio.rig_urls.is_empty());
assert_eq!(config.frontends.audio.server_port, 4531);
assert!(config.frontends.audio.rig_ports.is_empty());
assert!(!config.frontends.audio.bridge.enabled);
@@ -825,6 +849,28 @@ uhf = 60
);
}
#[test]
fn test_parse_client_toml_with_audio_urls() {
let toml_str = r#"
[frontends.audio]
enabled = true
server_url = "tcp://audio.example.com"
[frontends.audio.rig_urls]
home-hf = "audio://10.0.0.5:4600"
"#;
let config: ClientConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.frontends.audio.server_url,
Some("tcp://audio.example.com".to_string())
);
assert_eq!(
config.frontends.audio.rig_urls.get("home-hf"),
Some(&"audio://10.0.0.5:4600".to_string())
);
}
#[test]
fn test_example_combined_toml_parses() {
let example = ClientConfig::example_combined_toml();
@@ -865,6 +911,21 @@ uhf = 60
assert!(config.validate().is_err());
}
#[test]
fn test_validate_rejects_invalid_audio_url() {
let mut config = ClientConfig::default();
config.frontends.audio.server_url = Some("tcp://:4531".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_validate_accepts_audio_url_without_server_port() {
let mut config = ClientConfig::default();
config.frontends.audio.server_url = Some("audio.example.com".to_string());
config.frontends.audio.server_port = 0;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_http_auth_enabled_without_passphrases() {
let mut config = ClientConfig::default();
@@ -1080,7 +1141,10 @@ url = "remote.example.com:4530"
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
}];
assert!(config.validate().unwrap_err().contains("name must not be empty"));
assert!(config
.validate()
.unwrap_err()
.contains("name must not be empty"));
}
#[test]
@@ -1093,7 +1157,10 @@ url = "remote.example.com:4530"
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
}];
assert!(config.validate().unwrap_err().contains("url must not be empty"));
assert!(config
.validate()
.unwrap_err()
.contains("url must not be empty"));
}
#[test]
@@ -1106,6 +1173,9 @@ url = "remote.example.com:4530"
auth: RemoteAuthConfig::default(),
poll_interval_ms: 0,
}];
assert!(config.validate().unwrap_err().contains("poll_interval_ms must be > 0"));
assert!(config
.validate()
.unwrap_err()
.contains("poll_interval_ms must be > 0"));
}
}
+56 -37
View File
@@ -32,8 +32,9 @@ use trx_frontend_http::register_frontend_on as register_http_frontend;
use trx_frontend_http_json::register_frontend_on as register_http_json_frontend;
use trx_frontend_rigctl::register_frontend_on as register_rigctl_frontend;
use audio_client::AudioConnectConfig;
use config::{ClientConfig, RemoteEntry};
use remote_client::{parse_remote_url, RemoteClientConfig};
use remote_client::{parse_audio_url, parse_remote_url, RemoteClientConfig};
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - remote rig client");
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
@@ -194,10 +195,7 @@ async fn async_init() -> DynResult<AppState> {
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
// CLI --url creates a single implicit remote entry
let rig_id = cli
.rig_id
.clone()
.or_else(|| cfg.remote.rig_id.clone());
let rig_id = cli.rig_id.clone().or_else(|| cfg.remote.rig_id.clone());
let name = rig_id.clone().unwrap_or_else(|| "default".to_string());
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
@@ -304,17 +302,44 @@ async fn async_init() -> DynResult<AppState> {
})
.collect::<Result<Vec<_>, String>>()?;
// Build per-short-name server host map for audio routing.
let mut audio_server_hosts: HashMap<String, (String, u16)> = HashMap::new();
let global_audio_addr = cfg
.frontends
.audio
.server_url
.as_deref()
.map(|url| {
parse_audio_url(url)
.map(|endpoint| endpoint.connect_addr())
.map_err(|e| format!("Invalid audio URL override '{}': {}", url, e))
})
.transpose()?;
// Build per-short-name audio connection defaults.
let mut audio_connect: HashMap<String, AudioConnectConfig> = HashMap::new();
for (entry, ep) in &parsed_remotes {
let audio_port = cfg
.frontends
.audio
.rig_ports
.get(&entry.name)
.copied()
.unwrap_or(cfg.frontends.audio.server_port);
audio_server_hosts.insert(entry.name.clone(), (ep.host.clone(), audio_port));
let connect = if let Some(url) = cfg.frontends.audio.rig_urls.get(&entry.name) {
let addr = parse_audio_url(url)
.map(|endpoint| endpoint.connect_addr())
.map_err(|e| {
format!(
"Invalid audio URL override for remote '{}': {}",
entry.name, e
)
})?;
AudioConnectConfig::fixed(addr)
} else if let Some(addr) = global_audio_addr.clone() {
AudioConnectConfig::fixed(addr)
} else {
let audio_port = cfg
.frontends
.audio
.rig_ports
.get(&entry.name)
.copied()
.unwrap_or(cfg.frontends.audio.server_port);
AudioConnectConfig::from_host_port(ep.host.clone(), audio_port)
};
audio_connect.insert(entry.name.clone(), connect);
}
// Group by (connect_addr, token).
@@ -365,9 +390,13 @@ async fn async_init() -> DynResult<AppState> {
let state_tx = state_tx.clone();
let remote_shutdown_rx = shutdown_rx.clone();
task_handles.push(tokio::spawn(async move {
if let Err(e) =
remote_client::run_remote_client(remote_cfg, server_rx, state_tx, remote_shutdown_rx)
.await
if let Err(e) = remote_client::run_remote_client(
remote_cfg,
server_rx,
state_tx,
remote_shutdown_rx,
)
.await
{
error!("Remote client error: {}", e);
}
@@ -388,12 +417,7 @@ async fn async_init() -> DynResult<AppState> {
.rig_id_override
.as_deref()
.map(String::from)
.or_else(|| {
default_rig_for_router
.lock()
.ok()
.and_then(|g| g.clone())
});
.or_else(|| default_rig_for_router.lock().ok().and_then(|g| g.clone()));
let sender = target
.as_deref()
.and_then(|name| route_map.get(name))
@@ -501,26 +525,21 @@ async fn async_init() -> DynResult<AppState> {
}
});
info!(
"Audio enabled: default port {}, decode channel set",
cfg.frontends.audio.server_port
);
info!("Audio enabled: decode channel set");
let audio_rig_ports: HashMap<String, u16> = cfg.frontends.audio.rig_ports.clone();
let audio_shutdown_rx = shutdown_rx.clone();
let vchan_audio_map = frontend_runtime.vchan_audio.clone();
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();
let audio_rig_server_hosts: HashMap<String, String> = audio_server_hosts
.iter()
.map(|(name, (host, _))| (name.clone(), host.clone()))
.collect();
let default_audio_connect = if let Some(addr) = global_audio_addr {
AudioConnectConfig::fixed(addr)
} else {
AudioConnectConfig::from_host_port(remote_host.clone(), cfg.frontends.audio.server_port)
};
pending_audio_client = Some(tokio::spawn(audio_client::run_multi_rig_audio_manager(
remote_host,
cfg.frontends.audio.server_port,
audio_rig_ports,
audio_rig_server_hosts,
default_audio_connect,
audio_connect,
frontend_runtime.remote_active_rig_id.clone(),
frontend_runtime.remote_rigs.clone(),
rx_audio_tx,
+62 -33
View File
@@ -23,6 +23,7 @@ use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse};
const DEFAULT_REMOTE_PORT: u16 = 4530;
const DEFAULT_AUDIO_PORT: u16 = 4531;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const IO_TIMEOUT: Duration = Duration::from_secs(15);
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
@@ -426,10 +427,7 @@ async fn refresh_remote_snapshot(
// Track which wildcard (None-key) entry we've already resolved.
let mut wildcard_resolved = false;
for entry in &rigs {
if let Some(short_name) = config
.rig_id_to_short_name
.get(&Some(entry.rig_id.clone()))
{
if let Some(short_name) = config.rig_id_to_short_name.get(&Some(entry.rig_id.clone())) {
// Update reverse map.
if let Ok(mut rev) = config.short_name_to_rig_id.write() {
rev.insert(short_name.clone(), entry.rig_id.clone());
@@ -456,9 +454,7 @@ async fn refresh_remote_snapshot(
}
mapped
} else {
rigs.iter()
.map(|e| (e.rig_id.clone(), e))
.collect()
rigs.iter().map(|e| (e.rig_id.clone(), e)).collect()
};
cache_remote_rigs(config, &rigs, &mapped_rigs);
@@ -602,7 +598,10 @@ fn resolve_short_name(config: &RemoteClientConfig, server_rig_id: &str) -> Optio
return Some(server_rig_id.to_string());
}
// Try explicit rig_id mapping first.
if let Some(name) = config.rig_id_to_short_name.get(&Some(server_rig_id.to_string())) {
if let Some(name) = config
.rig_id_to_short_name
.get(&Some(server_rig_id.to_string()))
{
return Some(name.clone());
}
// Try wildcard (None key = "default rig on this server").
@@ -777,35 +776,44 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
}
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err("remote url is empty".into());
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr)
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str) -> Result<RemoteEndpoint, String> {
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or("invalid remote url: missing closing ']' for IPv6 host")?;
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err("invalid remote url: host is empty".into());
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
DEFAULT_REMOTE_PORT
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str)?
parse_port(port_str, kind)?
} else {
return Err("invalid remote url: expected ':<port>' after ']'".into());
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
@@ -815,41 +823,45 @@ fn parse_host_port(input: &str) -> Result<RemoteEndpoint, String> {
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err("invalid remote url: IPv6 host must be bracketed like [::1]:4532".into());
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or("invalid remote url: expected host:port")?;
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err("invalid remote url: host is empty".into());
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str)?,
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: DEFAULT_REMOTE_PORT,
port: default_port,
})
}
fn parse_port(port_str: &str) -> Result<u16, String> {
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid remote port: '{port_str}'"))?;
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err("invalid remote port: 0".into());
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::{parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum};
#[allow(unused_imports)]
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
use super::{
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, RwLock};
@@ -878,6 +890,18 @@ mod tests {
);
}
#[test]
fn parse_audio_host_default_port() {
let parsed = parse_audio_url("audio.example.local").expect("must parse");
assert_eq!(
parsed,
RemoteEndpoint {
host: "audio.example.local".to_string(),
port: 4531
}
);
}
#[test]
fn parse_ipv4_with_port() {
let parsed = parse_remote_url("tcp://127.0.0.1:9000").expect("must parse");
@@ -1092,9 +1116,10 @@ mod tests {
#[test]
fn build_envelope_translates_short_name_to_server_rig_id() {
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
let short_name_to_rig_id = Arc::new(RwLock::new(HashMap::from([
("home-hf".to_string(), "hf".to_string()),
])));
let short_name_to_rig_id = Arc::new(RwLock::new(HashMap::from([(
"home-hf".to_string(),
"hf".to_string(),
)])));
let config = RemoteClientConfig {
addr: "127.0.0.1:4530".to_string(),
token: None,
@@ -1105,9 +1130,7 @@ mod tests {
server_connected: Arc::new(AtomicBool::new(false)),
rig_states: Arc::new(RwLock::new(HashMap::new())),
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_id_to_short_name: HashMap::from([
(Some("hf".to_string()), "home-hf".to_string()),
]),
rig_id_to_short_name: HashMap::from([(Some("hf".to_string()), "home-hf".to_string())]),
short_name_to_rig_id,
};
// selected_rig_id is "home-hf" (short name), envelope should translate to "hf"
@@ -1164,8 +1187,14 @@ mod tests {
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
};
assert!(has_short_names(&config));
assert_eq!(resolve_short_name(&config, "hf"), Some("home-hf".to_string()));
assert_eq!(
resolve_short_name(&config, "hf"),
Some("home-hf".to_string())
);
// Unknown rig_id falls through to wildcard
assert_eq!(resolve_short_name(&config, "unknown"), Some("default-rig".to_string()));
assert_eq!(
resolve_short_name(&config, "unknown"),
Some("default-rig".to_string())
);
}
}