Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
1735 lines
67 KiB
Rust
1735 lines
67 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::RwLock;
|
|
use std::time::Duration;
|
|
use std::{sync::Arc, sync::Mutex};
|
|
|
|
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::TcpStream;
|
|
use tokio::sync::{mpsc, watch};
|
|
use tokio::time::{self, Instant};
|
|
use tracing::{info, warn};
|
|
|
|
use trx_core::rig::request::RigRequest;
|
|
use trx_core::rig::state::RigState;
|
|
use trx_core::{RigError, RigResult};
|
|
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
|
use trx_protocol::rig_command_to_client;
|
|
use trx_protocol::types::RigEntry;
|
|
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
|
|
|
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);
|
|
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
|
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RemoteEndpoint {
|
|
pub host: String,
|
|
pub port: u16,
|
|
}
|
|
|
|
impl RemoteEndpoint {
|
|
pub fn connect_addr(&self) -> String {
|
|
if self.host.contains(':') && !self.host.starts_with('[') {
|
|
format!("[{}]:{}", self.host, self.port)
|
|
} else {
|
|
format!("{}:{}", self.host, self.port)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep remote spectrum reasonably responsive without returning to the old
|
|
// timeout churn caused by a much tighter request cadence.
|
|
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
|
|
#[derive(Clone)]
|
|
pub struct RemoteClientConfig {
|
|
pub addr: String,
|
|
pub token: Option<String>,
|
|
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
|
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
|
pub poll_interval: Duration,
|
|
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
|
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
|
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
|
pub server_connected: Arc<AtomicBool>,
|
|
/// Per-rig server connection flag. Keyed by short name (or rig_id in legacy mode).
|
|
/// Set to `true` once the rig appears in a successful GetRigs response, and to
|
|
/// `false` when this config's TCP connection drops. Allows the UI to freeze only
|
|
/// the affected rig's view rather than all rigs.
|
|
pub rig_server_connected: Arc<RwLock<HashMap<String, bool>>>,
|
|
pub rig_states: Arc<RwLock<HashMap<String, watch::Sender<RigState>>>>,
|
|
/// Per-rig spectrum watch senders, keyed by short name (or rig_id in legacy mode).
|
|
pub rig_spectrums: Arc<RwLock<HashMap<String, watch::Sender<SharedSpectrum>>>>,
|
|
/// Maps configured server rig_id (`Some`) or default/wildcard (`None`) to
|
|
/// a client-side short name. Empty in legacy single-remote mode.
|
|
pub rig_id_to_short_name: HashMap<Option<String>, String>,
|
|
/// Dynamically resolved reverse mapping: short_name → server rig_id.
|
|
/// Populated during `refresh_remote_snapshot` when short-name mode is active.
|
|
pub short_name_to_rig_id: Arc<RwLock<HashMap<String, String>>>,
|
|
/// Cached satellite pass predictions from the server (GetSatPasses).
|
|
pub sat_passes: Arc<RwLock<Option<trx_core::geo::PassPredictionResult>>>,
|
|
/// Per-rig meter watch senders, keyed by short name (or rig_id in legacy mode).
|
|
/// Populated lazily by the meter-connection supervisor.
|
|
pub rig_meters: Arc<RwLock<HashMap<String, watch::Sender<Option<MeterUpdate>>>>>,
|
|
}
|
|
|
|
pub async fn run_remote_client(
|
|
config: RemoteClientConfig,
|
|
mut rx: mpsc::Receiver<RigRequest>,
|
|
state_tx: watch::Sender<RigState>,
|
|
mut shutdown_rx: watch::Receiver<bool>,
|
|
) -> RigResult<()> {
|
|
// Spectrum polling runs on its own dedicated TCP connection so it never
|
|
// blocks state polls or user commands on the main connection.
|
|
let spectrum_task = tokio::spawn(run_spectrum_connection(config.clone(), shutdown_rx.clone()));
|
|
// Meter supervisor: spawns per-rig meter-streaming TCP connections as
|
|
// soon as short names are discovered. Runs independently so the meter
|
|
// bar in the UI updates at the full server-side 30 Hz without being
|
|
// gated on state polls or user commands.
|
|
let meter_supervisor = tokio::spawn(run_meter_supervisor(config.clone(), shutdown_rx.clone()));
|
|
|
|
let mut reconnect_delay = Duration::from_secs(1);
|
|
|
|
loop {
|
|
if *shutdown_rx.borrow() {
|
|
info!("Remote client shutting down");
|
|
spectrum_task.abort();
|
|
meter_supervisor.abort();
|
|
return Ok(());
|
|
}
|
|
|
|
info!("Remote client: connecting to {}", config.addr);
|
|
match time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&config.addr)).await {
|
|
Ok(Ok(stream)) => {
|
|
// Reset backoff on successful TCP connect: server is reachable, so the
|
|
// next disconnect should retry quickly rather than waiting up to 10 s.
|
|
reconnect_delay = Duration::from_secs(1);
|
|
// Disable Nagle's algorithm so each framed command is sent immediately
|
|
// rather than being held for up to 40 ms waiting for ACKs.
|
|
if let Err(e) = stream.set_nodelay(true) {
|
|
warn!("TCP_NODELAY failed: {}", e);
|
|
}
|
|
config.server_connected.store(true, Ordering::Relaxed);
|
|
if let Err(e) =
|
|
handle_connection(&config, stream, &mut rx, &state_tx, &mut shutdown_rx).await
|
|
{
|
|
warn!("Remote connection dropped: {}", e);
|
|
}
|
|
config.server_connected.store(false, Ordering::Relaxed);
|
|
// Collect the short names owned by this config so we can mark
|
|
// only their rigs as disconnected (other server groups unaffected).
|
|
let owned: Vec<String> = if has_short_names(&config) {
|
|
config.rig_id_to_short_name.values().cloned().collect()
|
|
} else {
|
|
// Legacy single-remote: every key in rig_server_connected is ours.
|
|
config
|
|
.rig_server_connected
|
|
.read()
|
|
.map(|m| m.keys().cloned().collect())
|
|
.unwrap_or_default()
|
|
};
|
|
if let Ok(mut conn_map) = config.rig_server_connected.write() {
|
|
for name in &owned {
|
|
conn_map.insert(name.clone(), false);
|
|
}
|
|
}
|
|
// Nudge each rig's watch so SSE clients see server_connected=false.
|
|
if let Ok(rig_map) = config.rig_states.read() {
|
|
for name in &owned {
|
|
if let Some(tx) = rig_map.get(name) {
|
|
tx.send_modify(|_| {});
|
|
}
|
|
}
|
|
}
|
|
// Also nudge the global state watch for backward compat.
|
|
state_tx.send_modify(|_| {});
|
|
}
|
|
Ok(Err(e)) => {
|
|
warn!("Remote connect failed: {}", e);
|
|
}
|
|
Err(_) => {
|
|
warn!("Remote connect timed out after {:?}", CONNECT_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
tokio::select! {
|
|
_ = time::sleep(reconnect_delay) => {}
|
|
changed = shutdown_rx.changed() => {
|
|
match changed {
|
|
Ok(()) if *shutdown_rx.borrow() => {
|
|
info!("Remote client shutting down");
|
|
spectrum_task.abort();
|
|
meter_supervisor.abort();
|
|
return Ok(());
|
|
}
|
|
Ok(()) => {}
|
|
Err(_) => {
|
|
spectrum_task.abort();
|
|
meter_supervisor.abort();
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(10));
|
|
}
|
|
}
|
|
|
|
/// Spectrum polling runs on a dedicated TCP connection so it never blocks
|
|
/// state polls or user commands on the main connection. Reconnects
|
|
/// independently with a short fixed delay.
|
|
async fn run_spectrum_connection(
|
|
config: RemoteClientConfig,
|
|
mut shutdown_rx: watch::Receiver<bool>,
|
|
) {
|
|
loop {
|
|
if *shutdown_rx.borrow() {
|
|
break;
|
|
}
|
|
|
|
match time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&config.addr)).await {
|
|
Ok(Ok(stream)) => {
|
|
if let Err(e) = stream.set_nodelay(true) {
|
|
warn!("Spectrum TCP_NODELAY failed: {}", e);
|
|
}
|
|
if let Err(e) = handle_spectrum_connection(&config, stream, &mut shutdown_rx).await
|
|
{
|
|
warn!("Spectrum connection dropped: {}", e);
|
|
}
|
|
// Mark spectrum unavailable while reconnecting.
|
|
config.spectrum.send_modify(|s| s.set(None, None));
|
|
}
|
|
Ok(Err(e)) => warn!("Spectrum connect failed: {}", e),
|
|
Err(_) => warn!("Spectrum connect timed out"),
|
|
}
|
|
|
|
tokio::select! {
|
|
_ = time::sleep(Duration::from_secs(1)) => {}
|
|
changed = shutdown_rx.changed() => {
|
|
if matches!(changed, Ok(()) | Err(_)) && *shutdown_rx.borrow() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Meter stream supervisor. Watches the set of known short names and spawns
|
|
/// one dedicated TCP connection per rig that streams `MeterUpdate` JSON lines
|
|
/// (see `trx_protocol::MeterUpdate`). Each per-rig task owns its own watch
|
|
/// sender in `config.rig_meters` and reconnects on failure.
|
|
async fn run_meter_supervisor(config: RemoteClientConfig, mut shutdown_rx: watch::Receiver<bool>) {
|
|
let mut tasks: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
|
|
let mut poll = time::interval(Duration::from_millis(500));
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = poll.tick() => {}
|
|
changed = shutdown_rx.changed() => {
|
|
if matches!(changed, Ok(()) | Err(_)) && *shutdown_rx.borrow() {
|
|
for (_, handle) in tasks.drain() {
|
|
handle.abort();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
let known = collect_known_short_names(&config);
|
|
for name in &known {
|
|
if tasks.contains_key(name) {
|
|
continue;
|
|
}
|
|
// Ensure a watch sender exists so SSE clients can subscribe
|
|
// before the first sample arrives.
|
|
{
|
|
if let Ok(mut map) = config.rig_meters.write() {
|
|
map.entry(name.clone())
|
|
.or_insert_with(|| watch::channel(None).0);
|
|
}
|
|
}
|
|
let task = tokio::spawn(run_meter_connection(
|
|
config.clone(),
|
|
name.clone(),
|
|
shutdown_rx.clone(),
|
|
));
|
|
tasks.insert(name.clone(), task);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_known_short_names(config: &RemoteClientConfig) -> Vec<String> {
|
|
let mut names: Vec<String> = Vec::new();
|
|
if has_short_names(config) {
|
|
names.extend(config.rig_id_to_short_name.values().cloned());
|
|
}
|
|
if let Ok(map) = config.rig_states.read() {
|
|
for name in map.keys() {
|
|
if !names.iter().any(|n| n == name) {
|
|
names.push(name.clone());
|
|
}
|
|
}
|
|
}
|
|
names
|
|
}
|
|
|
|
/// One dedicated TCP connection per short-name that sends `SubscribeMeter` and
|
|
/// pumps the server's `MeterUpdate` JSON stream into the per-rig watch sender.
|
|
async fn run_meter_connection(
|
|
config: RemoteClientConfig,
|
|
short_name: String,
|
|
mut shutdown_rx: watch::Receiver<bool>,
|
|
) {
|
|
loop {
|
|
if *shutdown_rx.borrow() {
|
|
return;
|
|
}
|
|
|
|
let stream = match time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&config.addr)).await {
|
|
Ok(Ok(s)) => s,
|
|
Ok(Err(e)) => {
|
|
warn!("Meter[{}]: connect failed: {}", short_name, e);
|
|
if wait_reconnect(&mut shutdown_rx, Duration::from_secs(1)).await {
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
Err(_) => {
|
|
warn!("Meter[{}]: connect timed out", short_name);
|
|
if wait_reconnect(&mut shutdown_rx, Duration::from_secs(1)).await {
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
let _ = stream.set_nodelay(true);
|
|
|
|
if let Err(e) = stream_meter(&config, &short_name, stream, &mut shutdown_rx).await {
|
|
warn!("Meter[{}]: stream ended: {}", short_name, e);
|
|
}
|
|
|
|
if wait_reconnect(&mut shutdown_rx, Duration::from_secs(1)).await {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_reconnect(shutdown_rx: &mut watch::Receiver<bool>, delay: Duration) -> bool {
|
|
tokio::select! {
|
|
_ = time::sleep(delay) => false,
|
|
changed = shutdown_rx.changed() => {
|
|
matches!(changed, Ok(()) | Err(_)) && *shutdown_rx.borrow()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn stream_meter(
|
|
config: &RemoteClientConfig,
|
|
short_name: &str,
|
|
stream: TcpStream,
|
|
shutdown_rx: &mut watch::Receiver<bool>,
|
|
) -> RigResult<()> {
|
|
let (reader, mut writer) = stream.into_split();
|
|
let mut reader = BufReader::new(reader);
|
|
|
|
let envelope = build_envelope(
|
|
config,
|
|
ClientCommand::SubscribeMeter,
|
|
Some(short_name.to_string()),
|
|
);
|
|
let mut payload = serde_json::to_string(&envelope)
|
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
|
payload.push('\n');
|
|
time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
|
.await
|
|
.map_err(|_| RigError::communication("meter subscribe write timed out".to_string()))?
|
|
.map_err(|e| RigError::communication(format!("meter subscribe write failed: {e}")))?;
|
|
time::timeout(IO_TIMEOUT, writer.flush())
|
|
.await
|
|
.map_err(|_| RigError::communication("meter subscribe flush timed out".to_string()))?
|
|
.map_err(|e| RigError::communication(format!("meter subscribe flush failed: {e}")))?;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
changed = shutdown_rx.changed() => {
|
|
match changed {
|
|
Ok(()) if *shutdown_rx.borrow() => return Ok(()),
|
|
Ok(()) => {}
|
|
Err(_) => return Ok(()),
|
|
}
|
|
}
|
|
line = read_limited_line(&mut reader, MAX_JSON_LINE_BYTES) => {
|
|
let line = line
|
|
.map_err(|e| RigError::communication(format!("meter read failed: {e}")))?
|
|
.ok_or_else(|| RigError::communication("meter connection closed".to_string()))?;
|
|
let trimmed = line.trim_end();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let update: MeterUpdate = match serde_json::from_str(trimmed) {
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
warn!("Meter[{}]: bad frame: {}", short_name, e);
|
|
continue;
|
|
}
|
|
};
|
|
if let Ok(map) = config.rig_meters.read() {
|
|
if let Some(tx) = map.get(short_name) {
|
|
// `watch` keeps only the latest value; slow SSE
|
|
// readers simply skip intermediate samples, which is
|
|
// exactly the desired behaviour for a meter display.
|
|
let _ = tx.send(Some(update));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Satellite pass prediction refresh runs on a dedicated TCP connection so it
|
|
/// never blocks state polls or user commands on the main connection.
|
|
/// Fetches immediately on connect, then every 5 minutes.
|
|
async fn run_sat_pass_connection(
|
|
config: RemoteClientConfig,
|
|
mut shutdown_rx: watch::Receiver<bool>,
|
|
) {
|
|
const SAT_PASS_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
|
// Allow extra time for server-side SGP4 computation.
|
|
const SAT_PASS_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
loop {
|
|
if *shutdown_rx.borrow() {
|
|
break;
|
|
}
|
|
|
|
match time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&config.addr)).await {
|
|
Ok(Ok(stream)) => {
|
|
let _ = stream.set_nodelay(true);
|
|
let (reader, mut writer) = stream.into_split();
|
|
let mut reader = BufReader::new(reader);
|
|
let mut interval = time::interval(SAT_PASS_REFRESH_INTERVAL);
|
|
|
|
'inner: loop {
|
|
tokio::select! {
|
|
changed = shutdown_rx.changed() => {
|
|
match changed {
|
|
Ok(()) if *shutdown_rx.borrow() => return,
|
|
Ok(()) => {}
|
|
Err(_) => return,
|
|
}
|
|
}
|
|
_ = interval.tick() => {
|
|
match send_get_sat_passes_on(
|
|
&config,
|
|
&mut writer,
|
|
&mut reader,
|
|
SAT_PASS_IO_TIMEOUT,
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
if let Ok(mut guard) = config.sat_passes.write() {
|
|
*guard = Some(result);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!("Sat passes refresh failed: {}", e);
|
|
break 'inner;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(Err(e)) => warn!("Sat-pass connect failed: {}", e),
|
|
Err(_) => warn!("Sat-pass connect timed out"),
|
|
}
|
|
|
|
tokio::select! {
|
|
_ = time::sleep(Duration::from_secs(5)) => {}
|
|
changed = shutdown_rx.changed() => {
|
|
if matches!(changed, Ok(()) | Err(_)) && *shutdown_rx.borrow() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a GetSatPasses request on the given connection with a custom timeout.
|
|
async fn send_get_sat_passes_on(
|
|
config: &RemoteClientConfig,
|
|
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
|
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
|
timeout: Duration,
|
|
) -> RigResult<trx_core::geo::PassPredictionResult> {
|
|
let envelope = build_envelope(config, ClientCommand::GetSatPasses, None);
|
|
let mut payload = serde_json::to_string(&envelope)
|
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
|
payload.push('\n');
|
|
|
|
time::timeout(timeout, writer.write_all(payload.as_bytes()))
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("write timed out after {timeout:?}")))?
|
|
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
|
time::timeout(timeout, writer.flush())
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("flush timed out after {timeout:?}")))?
|
|
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
|
|
|
|
let line = time::timeout(timeout, read_limited_line(reader, MAX_JSON_LINE_BYTES))
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("read timed out after {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 {
|
|
return resp
|
|
.sat_passes
|
|
.ok_or_else(|| RigError::communication("missing sat_passes in GetSatPasses response"));
|
|
}
|
|
|
|
Err(RigError::communication(
|
|
resp.error.unwrap_or_else(|| "remote error".into()),
|
|
))
|
|
}
|
|
|
|
async fn handle_spectrum_connection(
|
|
config: &RemoteClientConfig,
|
|
stream: TcpStream,
|
|
shutdown_rx: &mut watch::Receiver<bool>,
|
|
) -> RigResult<()> {
|
|
let (reader, mut writer) = stream.into_split();
|
|
let mut reader = BufReader::new(reader);
|
|
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
|
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
|
let cached_token = config.token.clone();
|
|
|
|
loop {
|
|
tokio::select! {
|
|
changed = shutdown_rx.changed() => {
|
|
match changed {
|
|
Ok(()) if *shutdown_rx.borrow() => return Ok(()),
|
|
Ok(()) => {}
|
|
Err(_) => return Ok(()),
|
|
}
|
|
}
|
|
_ = interval.tick() => {
|
|
// Collect rig IDs that have active spectrum subscribers.
|
|
let rig_ids = active_spectrum_rig_ids(config);
|
|
|
|
if rig_ids.is_empty() {
|
|
// This server currently has no owned spectrum subscribers.
|
|
continue;
|
|
}
|
|
|
|
// Determine the currently selected rig for backward compat.
|
|
let selected = selected_rig_id(config);
|
|
|
|
for short_name in &rig_ids {
|
|
// Resolve the server rig_id for the wire envelope.
|
|
let wire_rig_id = if has_short_names(config) {
|
|
resolve_server_rig_id(config, short_name)
|
|
} else {
|
|
Some(short_name.clone())
|
|
};
|
|
let envelope = ClientEnvelope {
|
|
token: cached_token.clone(),
|
|
rig_id: wire_rig_id,
|
|
cmd: ClientCommand::GetSpectrum,
|
|
protocol_version: Some(trx_protocol::types::PROTOCOL_VERSION),
|
|
};
|
|
match send_envelope_no_state_update(&mut writer, &mut reader, envelope).await {
|
|
Ok(snapshot) => {
|
|
// Update per-rig channel (keyed by short name).
|
|
if let Ok(map) = config.rig_spectrums.read() {
|
|
if let Some(tx) = map.get(short_name) {
|
|
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(short_name.as_str());
|
|
if is_selected {
|
|
config.spectrum.send_modify(|s| s.set(snapshot.spectrum, snapshot.vchan_rds));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if selected
|
|
.as_deref()
|
|
.is_some_and(|rid| rid == short_name && config_owns_short_name(config, rid))
|
|
{
|
|
config.spectrum.send_modify(|s| s.set(None, None));
|
|
}
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_connection(
|
|
config: &RemoteClientConfig,
|
|
stream: TcpStream,
|
|
rx: &mut mpsc::Receiver<RigRequest>,
|
|
state_tx: &watch::Sender<RigState>,
|
|
shutdown_rx: &mut watch::Receiver<bool>,
|
|
) -> RigResult<()> {
|
|
let (reader, mut writer) = stream.into_split();
|
|
let mut reader = BufReader::new(reader);
|
|
let mut poll_interval = time::interval(config.poll_interval);
|
|
let mut last_poll = Instant::now();
|
|
let mut poll_failure_streak: u32 = 0;
|
|
|
|
// Prime rig list/state immediately after connect so frontends can render
|
|
// rig selectors without waiting for the first poll interval.
|
|
if let Err(e) = refresh_remote_snapshot(config, &mut writer, &mut reader, state_tx).await {
|
|
warn!("Initial remote snapshot refresh failed: {}", e);
|
|
}
|
|
|
|
// Satellite pass refresh runs on its own dedicated TCP connection so it
|
|
// never blocks state polls or user commands on the main connection.
|
|
let sat_pass_task = tokio::spawn(run_sat_pass_connection(config.clone(), shutdown_rx.clone()));
|
|
|
|
loop {
|
|
tokio::select! {
|
|
changed = shutdown_rx.changed() => {
|
|
match changed {
|
|
Ok(()) if *shutdown_rx.borrow() => {
|
|
sat_pass_task.abort();
|
|
return Ok(());
|
|
}
|
|
Ok(()) => {}
|
|
Err(_) => {
|
|
sat_pass_task.abort();
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
_ = poll_interval.tick() => {
|
|
if last_poll.elapsed() < config.poll_interval {
|
|
continue;
|
|
}
|
|
last_poll = Instant::now();
|
|
if let Err(e) =
|
|
refresh_remote_snapshot(config, &mut writer, &mut reader, state_tx).await
|
|
{
|
|
poll_failure_streak = poll_failure_streak.saturating_add(1);
|
|
warn!(
|
|
"Remote poll failed (streak={}): {}",
|
|
poll_failure_streak, e
|
|
);
|
|
|
|
let timeout_or_disconnect =
|
|
e.message.contains("timed out")
|
|
|| e.message.contains("connection closed");
|
|
if timeout_or_disconnect {
|
|
sat_pass_task.abort();
|
|
return Err(e);
|
|
}
|
|
if poll_failure_streak >= MAX_CONSECUTIVE_POLL_FAILURES {
|
|
sat_pass_task.abort();
|
|
return Err(RigError::communication(format!(
|
|
"remote poll failed {} consecutive times: {}",
|
|
poll_failure_streak, e
|
|
)));
|
|
}
|
|
} else {
|
|
poll_failure_streak = 0;
|
|
}
|
|
}
|
|
req = rx.recv() => {
|
|
let Some(req) = req else {
|
|
return Ok(());
|
|
};
|
|
let rig_id_override = req.rig_id_override;
|
|
let cmd = req.cmd;
|
|
let result = {
|
|
let client_cmd = rig_command_to_client(cmd);
|
|
send_command(config, &mut writer, &mut reader, client_cmd, rig_id_override, state_tx).await
|
|
};
|
|
|
|
let _ = req.respond_to.send(result);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn send_command(
|
|
config: &RemoteClientConfig,
|
|
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
|
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
|
cmd: ClientCommand,
|
|
rig_id_override: Option<String>,
|
|
state_tx: &watch::Sender<RigState>,
|
|
) -> RigResult<trx_core::RigSnapshot> {
|
|
// Keep the original short name for per-rig channel update after response.
|
|
let channel_key_override = rig_id_override.clone();
|
|
let envelope = build_envelope(config, cmd, rig_id_override);
|
|
|
|
let mut payload = serde_json::to_string(&envelope)
|
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
|
payload.push('\n');
|
|
|
|
time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
|
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
|
time::timeout(IO_TIMEOUT, writer.flush())
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))?
|
|
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
|
|
|
|
let line = time::timeout(IO_TIMEOUT, read_limited_line(reader, MAX_JSON_LINE_BYTES))
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("read timed out after {:?}", 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 {
|
|
let new_state = RigState::from_snapshot(snapshot.clone());
|
|
// Update the per-rig watch channel first so SSE sessions
|
|
// subscribed to a specific rig see the change immediately
|
|
// instead of waiting for the next poll cycle.
|
|
let channel_key = channel_key_override.or_else(|| selected_rig_id(config));
|
|
if let Some(key) = channel_key {
|
|
if let Ok(map) = config.rig_states.read() {
|
|
if let Some(tx) = map.get(&key) {
|
|
tx.send_if_modified(|old| {
|
|
if *old == new_state {
|
|
false
|
|
} else {
|
|
*old = new_state.clone();
|
|
true
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Send to main state channel last (takes ownership, no clone).
|
|
let _ = state_tx.send(new_state);
|
|
return Ok(snapshot);
|
|
}
|
|
return Err(RigError::communication("missing snapshot"));
|
|
}
|
|
|
|
Err(RigError::communication(
|
|
resp.error.unwrap_or_else(|| "remote error".into()),
|
|
))
|
|
}
|
|
|
|
fn build_envelope(
|
|
config: &RemoteClientConfig,
|
|
cmd: ClientCommand,
|
|
rig_id_override: Option<String>,
|
|
) -> ClientEnvelope {
|
|
let rig_id = rig_id_override.or_else(|| selected_rig_id(config));
|
|
// In multi-server mode, the rig_id is actually a short name that needs to
|
|
// be translated back to the server-side rig_id for the wire envelope.
|
|
let wire_rig_id = if has_short_names(config) {
|
|
rig_id.and_then(|name| resolve_server_rig_id(config, &name))
|
|
} else {
|
|
rig_id
|
|
};
|
|
ClientEnvelope {
|
|
token: config.token.clone(),
|
|
rig_id: wire_rig_id,
|
|
cmd,
|
|
protocol_version: Some(trx_protocol::types::PROTOCOL_VERSION),
|
|
}
|
|
}
|
|
|
|
async fn refresh_remote_snapshot(
|
|
config: &RemoteClientConfig,
|
|
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
|
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
|
state_tx: &watch::Sender<RigState>,
|
|
) -> RigResult<()> {
|
|
let rigs = send_get_rigs(config, writer, reader).await?;
|
|
|
|
// In multi-server mode, filter rigs to only those that have a short name
|
|
// mapping, and populate the reverse mapping (short_name → server rig_id).
|
|
let mapped_rigs: Vec<(String, &RigEntry)> = if has_short_names(config) {
|
|
let mut mapped = Vec::new();
|
|
// 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())) {
|
|
// Update reverse map.
|
|
if let Ok(mut rev) = config.short_name_to_rig_id.write() {
|
|
rev.insert(short_name.clone(), entry.rig_id.clone());
|
|
}
|
|
mapped.push((short_name.clone(), entry));
|
|
} else if !wildcard_resolved {
|
|
if let Some(short_name) = config.rig_id_to_short_name.get(&None) {
|
|
// Wildcard: first unmatched rig gets the default short name.
|
|
// Prefer an initialized, TX-capable rig when possible.
|
|
let candidate = choose_default_rig(&rigs)
|
|
.filter(|r| {
|
|
!config
|
|
.rig_id_to_short_name
|
|
.contains_key(&Some(r.rig_id.clone()))
|
|
})
|
|
.unwrap_or(entry);
|
|
if let Ok(mut rev) = config.short_name_to_rig_id.write() {
|
|
rev.insert(short_name.clone(), candidate.rig_id.clone());
|
|
}
|
|
mapped.push((short_name.clone(), candidate));
|
|
wildcard_resolved = true;
|
|
}
|
|
}
|
|
}
|
|
mapped
|
|
} else {
|
|
rigs.iter().map(|e| (e.rig_id.clone(), e)).collect()
|
|
};
|
|
|
|
cache_remote_rigs(config, &rigs, &mapped_rigs);
|
|
|
|
if mapped_rigs.is_empty() {
|
|
return Err(RigError::communication("GetRigs returned no mapped rigs"));
|
|
}
|
|
|
|
// Determine target for global state_tx (backward compat).
|
|
let selected = selected_rig_id(config);
|
|
let target_key = global_target_for_snapshot(selected.as_deref(), &mapped_rigs);
|
|
|
|
if let Some((key, entry)) = target_key {
|
|
if selected.is_none() {
|
|
set_selected_rig_id(config, Some(key.clone()));
|
|
}
|
|
let new_state = RigState::from_snapshot(entry.state.clone());
|
|
state_tx.send_if_modified(|old| {
|
|
if *old == new_state {
|
|
false
|
|
} else {
|
|
*old = new_state;
|
|
true
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update per-rig watch channels keyed by short name (or rig_id in legacy mode).
|
|
if let Ok(mut rig_map) = config.rig_states.write() {
|
|
for (key, entry) in &mapped_rigs {
|
|
let new_state = RigState::from_snapshot(entry.state.clone());
|
|
if let Some(tx) = rig_map.get(key) {
|
|
tx.send_if_modified(|old| {
|
|
if *old == new_state {
|
|
false
|
|
} else {
|
|
*old = new_state;
|
|
true
|
|
}
|
|
});
|
|
} else {
|
|
let (tx, _rx) = watch::channel(new_state);
|
|
rig_map.insert(key.clone(), tx);
|
|
}
|
|
}
|
|
// Remove channels for keys no longer present on this server while
|
|
// keeping watch channels owned by other server groups intact.
|
|
let active_keys: std::collections::HashSet<&str> =
|
|
mapped_rigs.iter().map(|(k, _)| k.as_str()).collect();
|
|
rig_map.retain(|id, _| {
|
|
!config_owns_short_name(config, id) || active_keys.contains(id.as_str())
|
|
});
|
|
}
|
|
// Mark all mapped rigs as connected now that we have a live snapshot.
|
|
if let Ok(mut conn_map) = config.rig_server_connected.write() {
|
|
for (key, _) in &mapped_rigs {
|
|
conn_map.insert(key.clone(), true);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_get_rigs(
|
|
config: &RemoteClientConfig,
|
|
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
|
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
|
) -> RigResult<Vec<RigEntry>> {
|
|
let envelope = build_envelope(config, ClientCommand::GetRigs, None);
|
|
let mut payload = serde_json::to_string(&envelope)
|
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
|
payload.push('\n');
|
|
|
|
time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
|
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
|
time::timeout(IO_TIMEOUT, writer.flush())
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))?
|
|
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
|
|
|
|
let line = time::timeout(IO_TIMEOUT, read_limited_line(reader, MAX_JSON_LINE_BYTES))
|
|
.await
|
|
.map_err(|_| RigError::communication(format!("read timed out after {:?}", 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 {
|
|
return resp
|
|
.rigs
|
|
.ok_or_else(|| RigError::communication("missing rigs list in GetRigs response"));
|
|
}
|
|
|
|
Err(RigError::communication(
|
|
resp.error.unwrap_or_else(|| "remote error".into()),
|
|
))
|
|
}
|
|
|
|
fn cache_remote_rigs(
|
|
config: &RemoteClientConfig,
|
|
_raw_rigs: &[RigEntry],
|
|
mapped_rigs: &[(String, &RigEntry)],
|
|
) {
|
|
if let Ok(mut guard) = config.known_rigs.lock() {
|
|
let next = if has_short_names(config) {
|
|
merge_known_rigs(config, &guard, mapped_rigs)
|
|
} else {
|
|
mapped_rigs
|
|
.iter()
|
|
.map(|(key, entry)| remote_rig_entry_from_snapshot(key, entry))
|
|
.collect()
|
|
};
|
|
|
|
// Skip the Vec rebuild when the rig list is structurally unchanged.
|
|
let unchanged = guard.len() == next.len()
|
|
&& guard
|
|
.iter()
|
|
.zip(next.iter())
|
|
.all(|(cached, new)| same_remote_rig_entry(cached, new));
|
|
if unchanged {
|
|
return;
|
|
}
|
|
*guard = next;
|
|
}
|
|
}
|
|
|
|
fn merge_known_rigs(
|
|
config: &RemoteClientConfig,
|
|
current: &[RemoteRigEntry],
|
|
mapped_rigs: &[(String, &RigEntry)],
|
|
) -> Vec<RemoteRigEntry> {
|
|
let owned_keys: std::collections::HashSet<String> =
|
|
config.rig_id_to_short_name.values().cloned().collect();
|
|
let mapped_by_key: std::collections::HashMap<&str, &RigEntry> = mapped_rigs
|
|
.iter()
|
|
.map(|(key, entry)| (key.as_str(), *entry))
|
|
.collect();
|
|
let mut merged = Vec::with_capacity(current.len().max(mapped_rigs.len()));
|
|
let mut inserted: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
|
|
for existing in current {
|
|
if owned_keys.contains(&existing.rig_id) {
|
|
if let Some(entry) = mapped_by_key.get(existing.rig_id.as_str()) {
|
|
merged.push(remote_rig_entry_from_snapshot(&existing.rig_id, entry));
|
|
inserted.insert(existing.rig_id.as_str());
|
|
}
|
|
} else {
|
|
merged.push(existing.clone());
|
|
}
|
|
}
|
|
|
|
for (key, entry) in mapped_rigs {
|
|
if !inserted.contains(key.as_str()) {
|
|
merged.push(remote_rig_entry_from_snapshot(key, entry));
|
|
}
|
|
}
|
|
|
|
merged
|
|
}
|
|
|
|
fn remote_rig_entry_from_snapshot(key: &str, entry: &RigEntry) -> RemoteRigEntry {
|
|
RemoteRigEntry {
|
|
rig_id: key.to_string(),
|
|
display_name: entry.display_name.clone(),
|
|
state: entry.state.clone(),
|
|
audio_port: entry.audio_port,
|
|
}
|
|
}
|
|
|
|
fn same_remote_rig_entry(left: &RemoteRigEntry, right: &RemoteRigEntry) -> bool {
|
|
left.rig_id == right.rig_id
|
|
&& left.display_name == right.display_name
|
|
&& left.state.initialized == right.state.initialized
|
|
&& left.audio_port == right.audio_port
|
|
}
|
|
|
|
fn selected_rig_id(config: &RemoteClientConfig) -> Option<String> {
|
|
config.selected_rig_id.lock().ok().and_then(|g| g.clone())
|
|
}
|
|
|
|
fn global_target_for_snapshot<'a>(
|
|
selected: Option<&str>,
|
|
mapped_rigs: &'a [(String, &RigEntry)],
|
|
) -> Option<&'a (String, &'a RigEntry)> {
|
|
selected
|
|
.and_then(|id| mapped_rigs.iter().find(|(key, _)| key == id))
|
|
.or_else(|| {
|
|
if selected.is_none() {
|
|
mapped_rigs.first()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn config_owns_short_name(config: &RemoteClientConfig, short_name: &str) -> bool {
|
|
if !has_short_names(config) {
|
|
return true;
|
|
}
|
|
config
|
|
.rig_id_to_short_name
|
|
.values()
|
|
.any(|name| name == short_name)
|
|
}
|
|
|
|
/// Returns `true` when the config has short-name mappings (multi-server mode).
|
|
fn has_short_names(config: &RemoteClientConfig) -> bool {
|
|
!config.rig_id_to_short_name.is_empty()
|
|
}
|
|
|
|
/// Resolve a server rig_id to the client-side short name.
|
|
/// In legacy mode (no mappings), returns the rig_id unchanged.
|
|
#[cfg(test)]
|
|
fn resolve_short_name(config: &RemoteClientConfig, server_rig_id: &str) -> Option<String> {
|
|
if !has_short_names(config) {
|
|
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()))
|
|
{
|
|
return Some(name.clone());
|
|
}
|
|
// Try wildcard (None key = "default rig on this server").
|
|
config.rig_id_to_short_name.get(&None).cloned()
|
|
}
|
|
|
|
/// Resolve a client-side short name back to a server rig_id for building envelopes.
|
|
fn resolve_server_rig_id(config: &RemoteClientConfig, short_name: &str) -> Option<String> {
|
|
if !has_short_names(config) {
|
|
return Some(short_name.to_string());
|
|
}
|
|
config
|
|
.short_name_to_rig_id
|
|
.read()
|
|
.ok()
|
|
.and_then(|map| map.get(short_name).cloned())
|
|
}
|
|
|
|
fn set_selected_rig_id(config: &RemoteClientConfig, value: Option<String>) {
|
|
if let Ok(mut guard) = config.selected_rig_id.lock() {
|
|
*guard = value;
|
|
}
|
|
}
|
|
|
|
/// Send a pre-built envelope and return the snapshot without updating state.
|
|
async fn send_envelope_no_state_update(
|
|
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);
|
|
}
|
|
return Err(RigError::communication("missing snapshot"));
|
|
}
|
|
Err(RigError::communication(
|
|
resp.error.unwrap_or_else(|| "remote error".into()),
|
|
))
|
|
}
|
|
|
|
/// 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 && config_owns_short_name(config, rig_id) {
|
|
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 config_owns_short_name(config, &selected) && !ids.contains(&selected) {
|
|
// Only add if the rig is initialized.
|
|
let initialized = config
|
|
.known_rigs
|
|
.lock()
|
|
.ok()
|
|
.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
|
|
.iter()
|
|
.find(|e| &e.rig_id == id)
|
|
.map(|e| e.state.initialized)
|
|
.unwrap_or(true)
|
|
});
|
|
}
|
|
ids
|
|
}
|
|
|
|
fn choose_default_rig(rigs: &[RigEntry]) -> Option<&RigEntry> {
|
|
rigs.iter().max_by_key(|entry| {
|
|
let tx_capable = entry.state.info.capabilities.tx;
|
|
let initialized = entry.state.initialized;
|
|
// Prefer initialized TX-capable rigs; tie-break by rig_id for deterministic choice.
|
|
(tx_capable, initialized, entry.rig_id.as_str())
|
|
})
|
|
}
|
|
|
|
async fn read_limited_line<R: AsyncBufRead + Unpin>(
|
|
reader: &mut R,
|
|
max_bytes: usize,
|
|
) -> std::io::Result<Option<String>> {
|
|
let mut line = Vec::with_capacity(256);
|
|
loop {
|
|
let available = reader.fill_buf().await?;
|
|
if available.is_empty() {
|
|
if line.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let text = String::from_utf8(line).map_err(|e| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("line is not valid UTF-8: {e}"),
|
|
)
|
|
})?;
|
|
return Ok(Some(text));
|
|
}
|
|
|
|
if let Some(pos) = available.iter().position(|b| *b == b'\n') {
|
|
let chunk = &available[..=pos];
|
|
if line.len() + chunk.len() > max_bytes {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("line exceeds maximum size of {max_bytes} bytes"),
|
|
));
|
|
}
|
|
line.extend_from_slice(chunk);
|
|
reader.consume(pos + 1);
|
|
let text = String::from_utf8(line).map_err(|e| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("line is not valid UTF-8: {e}"),
|
|
)
|
|
})?;
|
|
return Ok(Some(text));
|
|
}
|
|
|
|
if line.len() + available.len() > max_bytes {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("line exceeds maximum size of {max_bytes} bytes"),
|
|
));
|
|
}
|
|
|
|
line.extend_from_slice(available);
|
|
let consumed = available.len();
|
|
reader.consume(consumed);
|
|
}
|
|
}
|
|
|
|
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(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, default_port, kind)
|
|
}
|
|
|
|
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_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(format!("invalid {kind} url: host is empty"));
|
|
}
|
|
let port = if remainder.is_empty() {
|
|
default_port
|
|
} else if let Some(port_str) = remainder.strip_prefix(':') {
|
|
parse_port(port_str, kind)?
|
|
} else {
|
|
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
|
|
};
|
|
return Ok(RemoteEndpoint {
|
|
host: host.to_string(),
|
|
port,
|
|
});
|
|
}
|
|
|
|
if input.contains(':') {
|
|
if input.matches(':').count() > 1 {
|
|
return Err(format!(
|
|
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
|
|
));
|
|
}
|
|
let (host, port_str) = input
|
|
.rsplit_once(':')
|
|
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
|
|
if host.is_empty() {
|
|
return Err(format!("invalid {kind} url: host is empty"));
|
|
}
|
|
return Ok(RemoteEndpoint {
|
|
host: host.to_string(),
|
|
port: parse_port(port_str, kind)?,
|
|
});
|
|
}
|
|
|
|
Ok(RemoteEndpoint {
|
|
host: input.to_string(),
|
|
port: default_port,
|
|
})
|
|
}
|
|
|
|
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
|
|
let port: u16 = port_str
|
|
.parse()
|
|
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
|
|
if port == 0 {
|
|
return Err(format!("invalid {kind} port: 0"));
|
|
}
|
|
Ok(port)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[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};
|
|
use std::time::Duration;
|
|
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::{mpsc, watch};
|
|
|
|
use trx_core::radio::freq::{Band, Freq};
|
|
use trx_core::rig::state::RigSnapshot;
|
|
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo, RigStatus, RigTxStatus};
|
|
use trx_core::{RigMode, RigState};
|
|
use trx_protocol::types::RigEntry;
|
|
use trx_protocol::ClientResponse;
|
|
|
|
#[test]
|
|
fn parse_host_default_port() {
|
|
let parsed = parse_remote_url("example.local").expect("must parse");
|
|
assert_eq!(
|
|
parsed,
|
|
RemoteEndpoint {
|
|
host: "example.local".to_string(),
|
|
port: 4530
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
assert_eq!(
|
|
parsed,
|
|
RemoteEndpoint {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 9000
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_bracketed_ipv6() {
|
|
let parsed = parse_remote_url("http-json://[::1]:7000").expect("must parse");
|
|
assert_eq!(
|
|
parsed,
|
|
RemoteEndpoint {
|
|
host: "::1".to_string(),
|
|
port: 7000
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reject_unbracketed_ipv6() {
|
|
let err = parse_remote_url("::1:7000").expect_err("must fail");
|
|
assert!(err.contains("must be bracketed"));
|
|
}
|
|
|
|
fn sample_snapshot() -> RigSnapshot {
|
|
RigSnapshot {
|
|
info: RigInfo {
|
|
manufacturer: "Test".to_string(),
|
|
model: "Dummy".to_string(),
|
|
revision: "1".to_string(),
|
|
capabilities: RigCapabilities {
|
|
min_freq_step_hz: 1,
|
|
supported_bands: vec![Band {
|
|
low_hz: 7_000_000,
|
|
high_hz: 7_200_000,
|
|
tx_allowed: true,
|
|
}],
|
|
supported_modes: vec![RigMode::USB],
|
|
num_vfos: 1,
|
|
lock: false,
|
|
lockable: true,
|
|
attenuator: false,
|
|
preamp: false,
|
|
rit: false,
|
|
rpt: false,
|
|
split: false,
|
|
tx: true,
|
|
tx_limit: true,
|
|
vfo_switch: true,
|
|
filter_controls: false,
|
|
signal_meter: true,
|
|
},
|
|
access: RigAccessMethod::Tcp {
|
|
addr: "127.0.0.1:1234".to_string(),
|
|
},
|
|
},
|
|
status: RigStatus {
|
|
freq: Freq { hz: 7_100_000 },
|
|
mode: RigMode::USB,
|
|
tx_en: false,
|
|
vfo: None,
|
|
tx: Some(RigTxStatus {
|
|
power: None,
|
|
limit: None,
|
|
swr: None,
|
|
alc: None,
|
|
}),
|
|
rx: None,
|
|
lock: Some(false),
|
|
},
|
|
band: None,
|
|
enabled: Some(true),
|
|
initialized: true,
|
|
server_callsign: Some("N0CALL".to_string()),
|
|
server_version: Some("test".to_string()),
|
|
server_build_date: Some("2026-01-01".to_string()),
|
|
server_latitude: None,
|
|
server_longitude: None,
|
|
pskreporter_status: Some("Disabled".to_string()),
|
|
aprs_is_status: Some("Disabled".to_string()),
|
|
decoders: trx_core::DecoderConfig::default(),
|
|
cw_auto: true,
|
|
cw_wpm: 15,
|
|
cw_tone_hz: 700,
|
|
filter: None,
|
|
spectrum: None,
|
|
vchan_rds: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires TCP bind permissions"]
|
|
async fn reconnects_and_updates_state_after_drop() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
let snapshot = sample_snapshot();
|
|
let response = serde_json::to_string(&ClientResponse {
|
|
success: true,
|
|
rig_id: Some("server".to_string()),
|
|
protocol_version: None,
|
|
state: None,
|
|
rigs: Some(vec![RigEntry {
|
|
rig_id: "default".to_string(),
|
|
display_name: None,
|
|
state: snapshot.clone(),
|
|
audio_port: Some(4531),
|
|
}]),
|
|
sat_passes: None,
|
|
error: None,
|
|
})
|
|
.expect("serialize response")
|
|
+ "\n";
|
|
|
|
let server = tokio::spawn(async move {
|
|
let (first, _) = listener.accept().await.expect("accept first");
|
|
let (first_reader, _) = first.into_split();
|
|
let mut first_reader = BufReader::new(first_reader);
|
|
let mut buf = String::new();
|
|
let _ = first_reader.read_line(&mut buf).await.expect("read first");
|
|
|
|
let (second, _) = listener.accept().await.expect("accept second");
|
|
let (second_reader, mut second_writer) = second.into_split();
|
|
let mut second_reader = BufReader::new(second_reader);
|
|
buf.clear();
|
|
let _ = second_reader
|
|
.read_line(&mut buf)
|
|
.await
|
|
.expect("read second");
|
|
second_writer
|
|
.write_all(response.as_bytes())
|
|
.await
|
|
.expect("write response");
|
|
second_writer.flush().await.expect("flush");
|
|
});
|
|
|
|
let (_req_tx, req_rx) = mpsc::channel(8);
|
|
let (state_tx, mut state_rx) = watch::channel(RigState::new_uninitialized());
|
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
|
|
|
let client = tokio::spawn(super::run_remote_client(
|
|
RemoteClientConfig {
|
|
addr: addr.to_string(),
|
|
token: None,
|
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
|
poll_interval: Duration::from_millis(100),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_id_to_short_name: HashMap::new(),
|
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
},
|
|
req_rx,
|
|
state_tx,
|
|
shutdown_rx,
|
|
));
|
|
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
if state_rx.borrow().initialized {
|
|
break;
|
|
}
|
|
state_rx.changed().await.expect("state channel");
|
|
}
|
|
})
|
|
.await
|
|
.expect("state update timeout");
|
|
assert_eq!(state_rx.borrow().status.freq.hz, 7_100_000);
|
|
|
|
let _ = shutdown_tx.send(true);
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
let _ = client.await;
|
|
})
|
|
.await
|
|
.expect("client shutdown timeout");
|
|
let _ = server.await;
|
|
}
|
|
|
|
#[test]
|
|
fn build_envelope_includes_rig_id() {
|
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
|
let config = RemoteClientConfig {
|
|
addr: "127.0.0.1:4530".to_string(),
|
|
token: Some("secret".to_string()),
|
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
|
poll_interval: Duration::from_millis(500),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_id_to_short_name: HashMap::new(),
|
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
};
|
|
let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState, None);
|
|
assert_eq!(envelope.token.as_deref(), Some("secret"));
|
|
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
|
}
|
|
|
|
#[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 config = RemoteClientConfig {
|
|
addr: "127.0.0.1:4530".to_string(),
|
|
token: None,
|
|
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
|
poll_interval: Duration::from_millis(500),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
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())]),
|
|
short_name_to_rig_id,
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
};
|
|
// selected_rig_id is "home-hf" (short name), envelope should translate to "hf"
|
|
let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState, None);
|
|
assert_eq!(envelope.rig_id.as_deref(), Some("hf"));
|
|
|
|
// Override with short name should also translate
|
|
let envelope = super::build_envelope(
|
|
&config,
|
|
trx_protocol::ClientCommand::GetState,
|
|
Some("home-hf".to_string()),
|
|
);
|
|
assert_eq!(envelope.rig_id.as_deref(), Some("hf"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_short_name_legacy_passthrough() {
|
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
|
let config = RemoteClientConfig {
|
|
addr: "127.0.0.1:4530".to_string(),
|
|
token: None,
|
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
|
poll_interval: Duration::from_millis(500),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
|
rig_id_to_short_name: HashMap::new(),
|
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
};
|
|
// Legacy mode: rig_id passes through unchanged
|
|
assert!(!has_short_names(&config));
|
|
assert_eq!(resolve_short_name(&config, "hf"), Some("hf".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_short_name_with_mapping() {
|
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
|
let config = RemoteClientConfig {
|
|
addr: "127.0.0.1:4530".to_string(),
|
|
token: None,
|
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
|
poll_interval: Duration::from_millis(500),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
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()),
|
|
(None, "default-rig".to_string()),
|
|
]),
|
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
};
|
|
assert!(has_short_names(&config));
|
|
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())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_remote_rigs_keeps_other_server_entries() {
|
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
|
let known_rigs = Arc::new(Mutex::new(vec![trx_frontend::RemoteRigEntry {
|
|
rig_id: "lidzbark-vhf".to_string(),
|
|
display_name: Some("Lidzbark VHF".to_string()),
|
|
state: sample_snapshot(),
|
|
audio_port: Some(4531),
|
|
}]));
|
|
let config = RemoteClientConfig {
|
|
addr: "127.0.0.1:4530".to_string(),
|
|
token: None,
|
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
|
known_rigs: known_rigs.clone(),
|
|
poll_interval: Duration::from_millis(500),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
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()), "gdansk".to_string())]),
|
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
};
|
|
let snapshot = sample_snapshot();
|
|
let rigs = vec![RigEntry {
|
|
rig_id: "hf".to_string(),
|
|
display_name: Some("Gdansk HF".to_string()),
|
|
state: snapshot,
|
|
audio_port: Some(4532),
|
|
}];
|
|
let mapped = vec![("gdansk".to_string(), &rigs[0])];
|
|
|
|
super::cache_remote_rigs(&config, &rigs, &mapped);
|
|
|
|
let cached = known_rigs.lock().expect("known rigs lock");
|
|
assert_eq!(cached.len(), 2);
|
|
assert!(cached.iter().any(|entry| entry.rig_id == "lidzbark-vhf"));
|
|
assert!(cached.iter().any(|entry| {
|
|
entry.rig_id == "gdansk"
|
|
&& entry.display_name.as_deref() == Some("Gdansk HF")
|
|
&& entry.audio_port == Some(4532)
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn global_target_for_snapshot_skips_other_server_selection() {
|
|
let snapshot = sample_snapshot();
|
|
let rigs = vec![RigEntry {
|
|
rig_id: "hf".to_string(),
|
|
display_name: Some("Gdansk HF".to_string()),
|
|
state: snapshot,
|
|
audio_port: Some(4532),
|
|
}];
|
|
let mapped = vec![("gdansk".to_string(), &rigs[0])];
|
|
|
|
let target = super::global_target_for_snapshot(Some("lidzbark-vhf"), &mapped);
|
|
assert!(target.is_none());
|
|
|
|
let target = super::global_target_for_snapshot(None, &mapped);
|
|
assert_eq!(target.map(|(key, _)| key.as_str()), Some("gdansk"));
|
|
}
|
|
|
|
#[test]
|
|
fn active_spectrum_rig_ids_only_returns_owned_selected_rig() {
|
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
|
let selected_rig_id = Arc::new(Mutex::new(Some("lidzbark-vhf".to_string())));
|
|
let known_rigs = Arc::new(Mutex::new(vec![
|
|
trx_frontend::RemoteRigEntry {
|
|
rig_id: "gdansk".to_string(),
|
|
display_name: Some("Gdansk".to_string()),
|
|
state: sample_snapshot(),
|
|
audio_port: Some(4532),
|
|
},
|
|
trx_frontend::RemoteRigEntry {
|
|
rig_id: "lidzbark-vhf".to_string(),
|
|
display_name: Some("Lidzbark VHF".to_string()),
|
|
state: sample_snapshot(),
|
|
audio_port: Some(4531),
|
|
},
|
|
]));
|
|
let config = RemoteClientConfig {
|
|
addr: "127.0.0.1:4530".to_string(),
|
|
token: None,
|
|
selected_rig_id,
|
|
known_rigs,
|
|
poll_interval: Duration::from_millis(500),
|
|
spectrum: Arc::new(spectrum_tx),
|
|
server_connected: Arc::new(AtomicBool::new(false)),
|
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
|
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()), "gdansk".to_string())]),
|
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
|
sat_passes: Arc::new(RwLock::new(None)),
|
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
|
};
|
|
|
|
let ids = super::active_spectrum_rig_ids(&config);
|
|
assert!(ids.is_empty());
|
|
}
|
|
}
|