[feat](trx-server): implement multi-rig support
Enable N simultaneous rig backends in one server process:
- rig_handle.rs: new RigHandle { rig_id, rig_tx, state_rx } thin struct
- config.rs: RigInstanceConfig, rigs: Vec<RigInstanceConfig> in ServerConfig,
resolved_rigs() (synthesises legacy flat fields as id="default"),
validate() checks unique rig IDs and audio ports; MR-08 config tests
- audio.rs: replace four OnceLock<Mutex<VecDeque>> statics with
DecoderHistories { aprs, ft8, wspr } Arc struct; decoder/listener
functions now take Arc<DecoderHistories> for per-rig isolation
- rig_task.rs: add rig_id + histories: Arc<DecoderHistories> to
RigTaskConfig; clear_*_history calls use ctx.histories instance methods
- listener.rs: run_listener takes Arc<HashMap<String, RigHandle>> +
default_rig_id; routes envelope.rig_id to correct rig; GetRigs fast
path aggregates all rig states; all responses include rig_id field
- main.rs: loop over resolved_rigs(); spawn_rig_audio_stack() helper;
builds Arc<HashMap<String, RigHandle>> passed to run_listener
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
+100
-85
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::{collections::VecDeque, sync::Mutex};
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
@@ -118,103 +118,112 @@ fn classify_stream_error(err: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aprs_history() -> &'static Mutex<VecDeque<(Instant, AprsPacket)>> {
|
/// Per-rig decoder history store.
|
||||||
static HISTORY: OnceLock<Mutex<VecDeque<(Instant, AprsPacket)>>> = OnceLock::new();
|
///
|
||||||
HISTORY.get_or_init(|| Mutex::new(VecDeque::new()))
|
/// Replaces the previous process-wide `OnceLock` statics so that each rig
|
||||||
|
/// instance can maintain its own independent history. Pass an
|
||||||
|
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
|
||||||
|
pub struct DecoderHistories {
|
||||||
|
aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||||
|
ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||||
|
wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prune_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
impl DecoderHistories {
|
||||||
let cutoff = Instant::now() - APRS_HISTORY_RETENTION;
|
pub fn new() -> Arc<Self> {
|
||||||
while let Some((ts, _)) = history.front() {
|
Arc::new(Self {
|
||||||
if *ts < cutoff {
|
aprs: Mutex::new(VecDeque::new()),
|
||||||
history.pop_front();
|
ft8: Mutex::new(VecDeque::new()),
|
||||||
} else {
|
wspr: Mutex::new(VecDeque::new()),
|
||||||
break;
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- APRS ---
|
||||||
|
|
||||||
|
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||||
|
let cutoff = Instant::now() - APRS_HISTORY_RETENTION;
|
||||||
|
while let Some((ts, _)) = history.front() {
|
||||||
|
if *ts < cutoff {
|
||||||
|
history.pop_front();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_aprs_packet(pkt: AprsPacket) {
|
pub fn record_aprs_packet(&self, pkt: AprsPacket) {
|
||||||
let mut history = aprs_history().lock().expect("aprs history mutex poisoned");
|
let mut h = self.aprs.lock().expect("aprs history mutex poisoned");
|
||||||
history.push_back((Instant::now(), pkt));
|
h.push_back((Instant::now(), pkt));
|
||||||
prune_aprs_history(&mut history);
|
Self::prune_aprs(&mut h);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot_aprs_history() -> Vec<AprsPacket> {
|
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
|
||||||
let mut history = aprs_history().lock().expect("aprs history mutex poisoned");
|
let mut h = self.aprs.lock().expect("aprs history mutex poisoned");
|
||||||
prune_aprs_history(&mut history);
|
Self::prune_aprs(&mut h);
|
||||||
history.iter().map(|(_, pkt)| pkt.clone()).collect()
|
h.iter().map(|(_, pkt)| pkt.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_aprs_history() {
|
pub fn clear_aprs_history(&self) {
|
||||||
let mut history = aprs_history().lock().expect("aprs history mutex poisoned");
|
self.aprs.lock().expect("aprs history mutex poisoned").clear();
|
||||||
history.clear();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn ft8_history() -> &'static Mutex<VecDeque<(Instant, Ft8Message)>> {
|
// --- FT8 ---
|
||||||
static HISTORY: OnceLock<Mutex<VecDeque<(Instant, Ft8Message)>>> = OnceLock::new();
|
|
||||||
HISTORY.get_or_init(|| Mutex::new(VecDeque::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prune_ft8_history(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
|
||||||
let cutoff = Instant::now() - FT8_HISTORY_RETENTION;
|
let cutoff = Instant::now() - FT8_HISTORY_RETENTION;
|
||||||
while let Some((ts, _)) = history.front() {
|
while let Some((ts, _)) = history.front() {
|
||||||
if *ts < cutoff {
|
if *ts < cutoff {
|
||||||
history.pop_front();
|
history.pop_front();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_ft8_message(msg: Ft8Message) {
|
pub fn record_ft8_message(&self, msg: Ft8Message) {
|
||||||
let mut history = ft8_history().lock().expect("ft8 history mutex poisoned");
|
let mut h = self.ft8.lock().expect("ft8 history mutex poisoned");
|
||||||
history.push_back((Instant::now(), msg));
|
h.push_back((Instant::now(), msg));
|
||||||
prune_ft8_history(&mut history);
|
Self::prune_ft8(&mut h);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot_ft8_history() -> Vec<Ft8Message> {
|
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
|
||||||
let mut history = ft8_history().lock().expect("ft8 history mutex poisoned");
|
let mut h = self.ft8.lock().expect("ft8 history mutex poisoned");
|
||||||
prune_ft8_history(&mut history);
|
Self::prune_ft8(&mut h);
|
||||||
history.iter().map(|(_, msg)| msg.clone()).collect()
|
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_ft8_history() {
|
pub fn clear_ft8_history(&self) {
|
||||||
let mut history = ft8_history().lock().expect("ft8 history mutex poisoned");
|
self.ft8.lock().expect("ft8 history mutex poisoned").clear();
|
||||||
history.clear();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn wspr_history() -> &'static Mutex<VecDeque<(Instant, WsprMessage)>> {
|
// --- WSPR ---
|
||||||
static HISTORY: OnceLock<Mutex<VecDeque<(Instant, WsprMessage)>>> = OnceLock::new();
|
|
||||||
HISTORY.get_or_init(|| Mutex::new(VecDeque::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prune_wspr_history(history: &mut VecDeque<(Instant, WsprMessage)>) {
|
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
|
||||||
let cutoff = Instant::now() - WSPR_HISTORY_RETENTION;
|
let cutoff = Instant::now() - WSPR_HISTORY_RETENTION;
|
||||||
while let Some((ts, _)) = history.front() {
|
while let Some((ts, _)) = history.front() {
|
||||||
if *ts < cutoff {
|
if *ts < cutoff {
|
||||||
history.pop_front();
|
history.pop_front();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot_wspr_history() -> Vec<WsprMessage> {
|
pub fn record_wspr_message(&self, msg: WsprMessage) {
|
||||||
let mut history = wspr_history().lock().expect("wspr history mutex poisoned");
|
let mut h = self.wspr.lock().expect("wspr history mutex poisoned");
|
||||||
prune_wspr_history(&mut history);
|
h.push_back((Instant::now(), msg));
|
||||||
history.iter().map(|(_, msg)| msg.clone()).collect()
|
Self::prune_wspr(&mut h);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_wspr_history() {
|
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
|
||||||
let mut history = wspr_history().lock().expect("wspr history mutex poisoned");
|
let mut h = self.wspr.lock().expect("wspr history mutex poisoned");
|
||||||
history.clear();
|
Self::prune_wspr(&mut h);
|
||||||
}
|
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_wspr_message(msg: WsprMessage) {
|
pub fn clear_wspr_history(&self) {
|
||||||
let mut history = wspr_history().lock().expect("wspr history mutex poisoned");
|
self.wspr.lock().expect("wspr history mutex poisoned").clear();
|
||||||
history.push_back((Instant::now(), msg));
|
}
|
||||||
prune_wspr_history(&mut history);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the audio capture thread.
|
/// Spawn the audio capture thread.
|
||||||
@@ -665,6 +674,7 @@ pub async fn run_aprs_decoder(
|
|||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
decode_logs: Option<Arc<DecoderLoggers>>,
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
) {
|
) {
|
||||||
info!("APRS decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("APRS decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = AprsDecoder::new(sample_rate);
|
let mut decoder = AprsDecoder::new(sample_rate);
|
||||||
@@ -717,7 +727,7 @@ pub async fn run_aprs_decoder(
|
|||||||
|
|
||||||
was_active = true;
|
was_active = true;
|
||||||
for pkt in decoder.process_samples(&mono) {
|
for pkt in decoder.process_samples(&mono) {
|
||||||
record_aprs_packet(pkt.clone());
|
histories.record_aprs_packet(pkt.clone());
|
||||||
if let Some(logger) = decode_logs.as_ref() {
|
if let Some(logger) = decode_logs.as_ref() {
|
||||||
logger.log_aprs(&pkt);
|
logger.log_aprs(&pkt);
|
||||||
}
|
}
|
||||||
@@ -936,6 +946,7 @@ pub async fn run_ft8_decoder(
|
|||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
decode_logs: Option<Arc<DecoderLoggers>>,
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
) {
|
) {
|
||||||
info!("FT8 decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("FT8 decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) {
|
let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) {
|
||||||
@@ -1020,7 +1031,7 @@ pub async fn run_ft8_decoder(
|
|||||||
freq_hz: res.freq_hz,
|
freq_hz: res.freq_hz,
|
||||||
message: res.text,
|
message: res.text,
|
||||||
};
|
};
|
||||||
record_ft8_message(msg.clone());
|
histories.record_ft8_message(msg.clone());
|
||||||
if let Some(logger) = decode_logs.as_ref() {
|
if let Some(logger) = decode_logs.as_ref() {
|
||||||
logger.log_ft8(&msg);
|
logger.log_ft8(&msg);
|
||||||
}
|
}
|
||||||
@@ -1072,6 +1083,7 @@ pub async fn run_wspr_decoder(
|
|||||||
mut state_rx: watch::Receiver<RigState>,
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
decode_logs: Option<Arc<DecoderLoggers>>,
|
decode_logs: Option<Arc<DecoderLoggers>>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
) {
|
) {
|
||||||
info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let decoder = match WsprDecoder::new() {
|
let decoder = match WsprDecoder::new() {
|
||||||
@@ -1136,7 +1148,7 @@ pub async fn run_wspr_decoder(
|
|||||||
freq_hz: res.freq_hz,
|
freq_hz: res.freq_hz,
|
||||||
message: res.message,
|
message: res.message,
|
||||||
};
|
};
|
||||||
record_wspr_message(msg.clone());
|
histories.record_wspr_message(msg.clone());
|
||||||
if let Some(logger) = decode_logs.as_ref() {
|
if let Some(logger) = decode_logs.as_ref() {
|
||||||
logger.log_wspr(&msg);
|
logger.log_wspr(&msg);
|
||||||
}
|
}
|
||||||
@@ -1209,6 +1221,7 @@ pub async fn run_audio_listener(
|
|||||||
stream_info: AudioStreamInfo,
|
stream_info: AudioStreamInfo,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
info!("Audio listener on {}", addr);
|
info!("Audio listener on {}", addr);
|
||||||
@@ -1224,9 +1237,10 @@ pub async fn run_audio_listener(
|
|||||||
let info = stream_info.clone();
|
let info = stream_info.clone();
|
||||||
let decode_tx = decode_tx.clone();
|
let decode_tx = decode_tx.clone();
|
||||||
let client_shutdown_rx = shutdown_rx.clone();
|
let client_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let client_histories = histories.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_audio_client(socket, peer, rx_audio, tx_audio, info, decode_tx, client_shutdown_rx).await {
|
if let Err(e) = handle_audio_client(socket, peer, rx_audio, tx_audio, info, decode_tx, client_shutdown_rx, client_histories).await {
|
||||||
warn!("Audio client {} error: {:?}", peer, e);
|
warn!("Audio client {} error: {:?}", peer, e);
|
||||||
}
|
}
|
||||||
info!("Audio client {} disconnected", peer);
|
info!("Audio client {} disconnected", peer);
|
||||||
@@ -1255,6 +1269,7 @@ async fn handle_audio_client(
|
|||||||
stream_info: AudioStreamInfo,
|
stream_info: AudioStreamInfo,
|
||||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let (reader, writer) = socket.into_split();
|
let (reader, writer) = socket.into_split();
|
||||||
let mut reader = tokio::io::BufReader::new(reader);
|
let mut reader = tokio::io::BufReader::new(reader);
|
||||||
@@ -1265,7 +1280,7 @@ async fn handle_audio_client(
|
|||||||
write_audio_msg(&mut writer, AUDIO_MSG_STREAM_INFO, &info_json).await?;
|
write_audio_msg(&mut writer, AUDIO_MSG_STREAM_INFO, &info_json).await?;
|
||||||
|
|
||||||
// Send APRS history to newly connected client.
|
// Send APRS history to newly connected client.
|
||||||
let history = snapshot_aprs_history();
|
let history = histories.snapshot_aprs_history();
|
||||||
for pkt in history {
|
for pkt in history {
|
||||||
let msg = DecodedMessage::Aprs(pkt);
|
let msg = DecodedMessage::Aprs(pkt);
|
||||||
let msg_type = AUDIO_MSG_APRS_DECODE;
|
let msg_type = AUDIO_MSG_APRS_DECODE;
|
||||||
@@ -1274,7 +1289,7 @@ async fn handle_audio_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Send FT8 history to newly connected client.
|
// Send FT8 history to newly connected client.
|
||||||
let history = snapshot_ft8_history();
|
let history = histories.snapshot_ft8_history();
|
||||||
for msg in history {
|
for msg in history {
|
||||||
let msg = DecodedMessage::Ft8(msg);
|
let msg = DecodedMessage::Ft8(msg);
|
||||||
let msg_type = AUDIO_MSG_FT8_DECODE;
|
let msg_type = AUDIO_MSG_FT8_DECODE;
|
||||||
@@ -1283,7 +1298,7 @@ async fn handle_audio_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Send WSPR history to newly connected client.
|
// Send WSPR history to newly connected client.
|
||||||
let history = snapshot_wspr_history();
|
let history = histories.snapshot_wspr_history();
|
||||||
for msg in history {
|
for msg in history {
|
||||||
let msg = DecodedMessage::Wspr(msg);
|
let msg = DecodedMessage::Wspr(msg);
|
||||||
let msg_type = AUDIO_MSG_WSPR_DECODE;
|
let msg_type = AUDIO_MSG_WSPR_DECODE;
|
||||||
|
|||||||
@@ -20,28 +20,74 @@ pub use trx_decode_log::DecodeLogsConfig;
|
|||||||
|
|
||||||
use trx_core::rig::state::RigMode;
|
use trx_core::rig::state::RigMode;
|
||||||
|
|
||||||
|
/// Per-rig instance configuration for multi-rig setups.
|
||||||
|
///
|
||||||
|
/// Each entry in `[[rigs]]` becomes one of these. The flat top-level
|
||||||
|
/// `[rig]` / `[audio]` / `[sdr]` / `[pskreporter]` / `[aprsfi]` /
|
||||||
|
/// `[behavior]` / `[decode_logs]` fields are still supported via
|
||||||
|
/// `ServerConfig::resolved_rigs()` which synthesises a single-element list
|
||||||
|
/// with `id = "default"` when `rigs` is empty.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct RigInstanceConfig {
|
||||||
|
/// Stable rig identifier used in protocol routing.
|
||||||
|
pub id: String,
|
||||||
|
/// Rig backend configuration.
|
||||||
|
pub rig: RigConfig,
|
||||||
|
/// Polling and retry behavior.
|
||||||
|
pub behavior: BehaviorConfig,
|
||||||
|
/// Audio streaming configuration for this rig.
|
||||||
|
pub audio: AudioConfig,
|
||||||
|
/// SDR pipeline configuration (only used when [rigs.rig.access] type = "sdr").
|
||||||
|
pub sdr: SdrConfig,
|
||||||
|
/// PSK Reporter uplink for this rig.
|
||||||
|
pub pskreporter: PskReporterConfig,
|
||||||
|
/// APRS-IS IGate uplink for this rig.
|
||||||
|
pub aprsfi: AprsFiConfig,
|
||||||
|
/// Decoder file logging for this rig.
|
||||||
|
pub decode_logs: DecodeLogsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RigInstanceConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: "default".to_string(),
|
||||||
|
rig: RigConfig::default(),
|
||||||
|
behavior: BehaviorConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
|
sdr: SdrConfig::default(),
|
||||||
|
pskreporter: PskReporterConfig::default(),
|
||||||
|
aprsfi: AprsFiConfig::default(),
|
||||||
|
decode_logs: DecodeLogsConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level server configuration structure.
|
/// Top-level server configuration structure.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
/// General settings
|
/// General settings
|
||||||
pub general: GeneralConfig,
|
pub general: GeneralConfig,
|
||||||
/// Rig backend configuration
|
/// Rig backend configuration (legacy flat; use [[rigs]] for multi-rig)
|
||||||
pub rig: RigConfig,
|
pub rig: RigConfig,
|
||||||
/// Polling and retry behavior
|
/// Polling and retry behavior (legacy flat)
|
||||||
pub behavior: BehaviorConfig,
|
pub behavior: BehaviorConfig,
|
||||||
/// TCP listener configuration
|
/// TCP listener configuration
|
||||||
pub listen: ListenConfig,
|
pub listen: ListenConfig,
|
||||||
/// Audio streaming configuration
|
/// Audio streaming configuration (legacy flat)
|
||||||
pub audio: AudioConfig,
|
pub audio: AudioConfig,
|
||||||
/// PSK Reporter uplink configuration
|
/// PSK Reporter uplink configuration (legacy flat)
|
||||||
pub pskreporter: PskReporterConfig,
|
pub pskreporter: PskReporterConfig,
|
||||||
/// APRS-IS IGate uplink configuration
|
/// APRS-IS IGate uplink configuration (legacy flat)
|
||||||
pub aprsfi: AprsFiConfig,
|
pub aprsfi: AprsFiConfig,
|
||||||
/// Decoder file logging configuration
|
/// Decoder file logging configuration (legacy flat)
|
||||||
pub decode_logs: DecodeLogsConfig,
|
pub decode_logs: DecodeLogsConfig,
|
||||||
/// SDR pipeline configuration (used when [rig.access] type = "sdr").
|
/// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr").
|
||||||
pub sdr: SdrConfig,
|
pub sdr: SdrConfig,
|
||||||
|
/// Multi-rig instance list. When non-empty, takes priority over the flat fields.
|
||||||
|
#[serde(rename = "rigs", default)]
|
||||||
|
pub rigs: Vec<RigInstanceConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// General application settings.
|
/// General application settings.
|
||||||
@@ -441,6 +487,33 @@ impl ServerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-rig uniqueness checks.
|
||||||
|
if !self.rigs.is_empty() {
|
||||||
|
let mut seen_ids: std::collections::HashSet<String> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
let mut seen_ports: std::collections::HashSet<u16> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
for rig in &self.rigs {
|
||||||
|
if rig.id.trim().is_empty() {
|
||||||
|
return Err("[[rigs]] entry has an empty id".to_string());
|
||||||
|
}
|
||||||
|
if !seen_ids.insert(rig.id.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"[[rigs]] duplicate rig id: \"{}\"",
|
||||||
|
rig.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if rig.audio.enabled {
|
||||||
|
if !seen_ports.insert(rig.audio.port) {
|
||||||
|
return Err(format!(
|
||||||
|
"[[rigs]] duplicate audio port {} (rig id: \"{}\")",
|
||||||
|
rig.audio.port, rig.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.decode_logs.enabled {
|
if self.decode_logs.enabled {
|
||||||
if self.decode_logs.dir.trim().is_empty() {
|
if self.decode_logs.dir.trim().is_empty() {
|
||||||
return Err("[decode_logs].dir must not be empty when enabled".to_string());
|
return Err("[decode_logs].dir must not be empty when enabled".to_string());
|
||||||
@@ -535,6 +608,27 @@ impl ServerConfig {
|
|||||||
<Self as ConfigFile>::load_from_default_paths()
|
<Self as ConfigFile>::load_from_default_paths()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the effective list of rig instances to spawn.
|
||||||
|
///
|
||||||
|
/// When `[[rigs]]` entries are present they are returned as-is.
|
||||||
|
/// Otherwise the legacy flat `[rig]` / `[audio]` / … fields are synthesised
|
||||||
|
/// into a single `RigInstanceConfig` with `id = "default"`.
|
||||||
|
pub fn resolved_rigs(&self) -> Vec<RigInstanceConfig> {
|
||||||
|
if !self.rigs.is_empty() {
|
||||||
|
return self.rigs.clone();
|
||||||
|
}
|
||||||
|
vec![RigInstanceConfig {
|
||||||
|
id: "default".to_string(),
|
||||||
|
rig: self.rig.clone(),
|
||||||
|
behavior: self.behavior.clone(),
|
||||||
|
audio: self.audio.clone(),
|
||||||
|
sdr: self.sdr.clone(),
|
||||||
|
pskreporter: self.pskreporter.clone(),
|
||||||
|
aprsfi: self.aprsfi.clone(),
|
||||||
|
decode_logs: self.decode_logs.clone(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate an example configuration as a TOML string.
|
/// Generate an example configuration as a TOML string.
|
||||||
pub fn example_toml() -> String {
|
pub fn example_toml() -> String {
|
||||||
let example = ServerConfig {
|
let example = ServerConfig {
|
||||||
@@ -564,6 +658,7 @@ impl ServerConfig {
|
|||||||
aprsfi: AprsFiConfig::default(),
|
aprsfi: AprsFiConfig::default(),
|
||||||
decode_logs: DecodeLogsConfig::default(),
|
decode_logs: DecodeLogsConfig::default(),
|
||||||
sdr: SdrConfig::default(),
|
sdr: SdrConfig::default(),
|
||||||
|
rigs: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
toml::to_string_pretty(&example).unwrap_or_default()
|
toml::to_string_pretty(&example).unwrap_or_default()
|
||||||
@@ -1018,4 +1113,167 @@ tokens = ["secret123"]
|
|||||||
errors
|
errors
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MR-08: multi-rig config tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolved_rigs_legacy_flat_fields() {
|
||||||
|
let mut cfg = ServerConfig::default();
|
||||||
|
cfg.rig.model = Some("ft817".to_string());
|
||||||
|
cfg.rig.access.access_type = Some("serial".to_string());
|
||||||
|
cfg.rig.access.port = Some("/dev/ttyUSB0".to_string());
|
||||||
|
cfg.rig.access.baud = Some(9600);
|
||||||
|
|
||||||
|
let rigs = cfg.resolved_rigs();
|
||||||
|
assert_eq!(rigs.len(), 1);
|
||||||
|
assert_eq!(rigs[0].id, "default");
|
||||||
|
assert_eq!(rigs[0].rig.model, Some("ft817".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolved_rigs_multi_rig_toml() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[general]
|
||||||
|
callsign = "W1AW"
|
||||||
|
|
||||||
|
[[rigs]]
|
||||||
|
id = "hf"
|
||||||
|
|
||||||
|
[rigs.rig]
|
||||||
|
model = "ft450d"
|
||||||
|
initial_freq_hz = 14074000
|
||||||
|
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
|
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4531
|
||||||
|
|
||||||
|
[[rigs]]
|
||||||
|
id = "sdr"
|
||||||
|
|
||||||
|
[rigs.rig]
|
||||||
|
model = "soapysdr"
|
||||||
|
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "sdr"
|
||||||
|
args = "driver=rtlsdr"
|
||||||
|
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4532
|
||||||
|
"#;
|
||||||
|
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
|
||||||
|
let rigs = cfg.resolved_rigs();
|
||||||
|
assert_eq!(rigs.len(), 2);
|
||||||
|
assert_eq!(rigs[0].id, "hf");
|
||||||
|
assert_eq!(rigs[0].rig.model, Some("ft450d".to_string()));
|
||||||
|
assert_eq!(rigs[0].audio.port, 4531);
|
||||||
|
assert_eq!(rigs[1].id, "sdr");
|
||||||
|
assert_eq!(rigs[1].rig.model, Some("soapysdr".to_string()));
|
||||||
|
assert_eq!(rigs[1].audio.port, 4532);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_rejects_duplicate_rig_ids() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[rigs]]
|
||||||
|
id = "rig1"
|
||||||
|
[rigs.rig]
|
||||||
|
model = "ft817"
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4531
|
||||||
|
|
||||||
|
[[rigs]]
|
||||||
|
id = "rig1"
|
||||||
|
[rigs.rig]
|
||||||
|
model = "ft450d"
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB1"
|
||||||
|
baud = 9600
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4532
|
||||||
|
"#;
|
||||||
|
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
|
||||||
|
let result = cfg.validate();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().contains("duplicate rig id"),
|
||||||
|
"expected error about duplicate rig id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_rejects_duplicate_audio_ports() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[rigs]]
|
||||||
|
id = "rig1"
|
||||||
|
[rigs.rig]
|
||||||
|
model = "ft817"
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4531
|
||||||
|
|
||||||
|
[[rigs]]
|
||||||
|
id = "rig2"
|
||||||
|
[rigs.rig]
|
||||||
|
model = "ft450d"
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB1"
|
||||||
|
baud = 9600
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4531
|
||||||
|
"#;
|
||||||
|
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
|
||||||
|
let result = cfg.validate();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().contains("duplicate audio port"),
|
||||||
|
"expected error about duplicate audio port"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_accepts_multi_rig_unique_ids_and_ports() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[rigs]]
|
||||||
|
id = "hf"
|
||||||
|
[rigs.rig]
|
||||||
|
model = "ft450d"
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "serial"
|
||||||
|
port = "/dev/ttyUSB0"
|
||||||
|
baud = 9600
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4531
|
||||||
|
|
||||||
|
[[rigs]]
|
||||||
|
id = "sdr"
|
||||||
|
[rigs.rig]
|
||||||
|
model = "soapysdr"
|
||||||
|
[rigs.rig.access]
|
||||||
|
type = "sdr"
|
||||||
|
args = "driver=rtlsdr"
|
||||||
|
[rigs.audio]
|
||||||
|
port = 4532
|
||||||
|
"#;
|
||||||
|
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
|
||||||
|
// validate() uses the flat [rig] field for rig-level checks; multi-rig
|
||||||
|
// validation focuses on ID/port uniqueness. The flat [rig] is default
|
||||||
|
// (no model), so the access check is skipped when both fields are absent.
|
||||||
|
assert!(
|
||||||
|
cfg.validate().is_ok(),
|
||||||
|
"expected Ok for valid multi-rig config"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-22
@@ -6,7 +6,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Accepts client connections speaking the `ClientEnvelope`/`ClientResponse`
|
//! Accepts client connections speaking the `ClientEnvelope`/`ClientResponse`
|
||||||
//! protocol defined in `trx-protocol`.
|
//! protocol defined in `trx-protocol`.
|
||||||
|
//!
|
||||||
|
//! Multi-rig routing: `ClientEnvelope.rig_id` selects the target rig.
|
||||||
|
//! When absent the first rig in the map is used (backward compat).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -14,28 +18,34 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::{mpsc, oneshot, watch};
|
use tokio::sync::{oneshot, watch};
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use trx_core::rig::command::RigCommand;
|
use trx_core::rig::command::RigCommand;
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
|
||||||
use trx_protocol::auth::{SimpleTokenValidator, TokenValidator};
|
use trx_protocol::auth::{SimpleTokenValidator, TokenValidator};
|
||||||
use trx_protocol::codec::parse_envelope;
|
use trx_protocol::codec::parse_envelope;
|
||||||
use trx_protocol::mapping;
|
use trx_protocol::mapping;
|
||||||
|
use trx_protocol::types::{ClientCommand, RigEntry};
|
||||||
use trx_protocol::ClientResponse;
|
use trx_protocol::ClientResponse;
|
||||||
|
|
||||||
|
use crate::rig_handle::RigHandle;
|
||||||
|
|
||||||
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
|
||||||
const MAX_JSON_LINE_BYTES: usize = 16 * 1024;
|
const MAX_JSON_LINE_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
/// Run the JSON TCP listener, accepting client connections.
|
/// Run the JSON TCP listener, accepting client connections.
|
||||||
|
///
|
||||||
|
/// `rigs` is a shared map from rig_id → `RigHandle`. The first entry (by
|
||||||
|
/// insertion order — deterministic after MR-07 iterates `resolved_rigs()` in
|
||||||
|
/// order) is the default rig for backward-compat clients that omit `rig_id`.
|
||||||
pub async fn run_listener(
|
pub async fn run_listener(
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
rig_tx: mpsc::Sender<RigRequest>,
|
rigs: Arc<HashMap<String, RigHandle>>,
|
||||||
|
default_rig_id: String,
|
||||||
auth_tokens: HashSet<String>,
|
auth_tokens: HashSet<String>,
|
||||||
state_rx: watch::Receiver<RigState>,
|
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
@@ -49,12 +59,12 @@ pub async fn run_listener(
|
|||||||
let (socket, peer) = accept?;
|
let (socket, peer) = accept?;
|
||||||
info!("Client connected: {}", peer);
|
info!("Client connected: {}", peer);
|
||||||
|
|
||||||
let tx = rig_tx.clone();
|
let rigs = Arc::clone(&rigs);
|
||||||
let srx = state_rx.clone();
|
let default_rig_id = default_rig_id.clone();
|
||||||
let validator = Arc::clone(&validator);
|
let validator = Arc::clone(&validator);
|
||||||
let client_shutdown_rx = shutdown_rx.clone();
|
let client_shutdown_rx = shutdown_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_client(socket, peer, tx, validator, srx, client_shutdown_rx).await {
|
if let Err(e) = handle_client(socket, peer, rigs, default_rig_id, validator, client_shutdown_rx).await {
|
||||||
error!("Client {} error: {:?}", peer, e);
|
error!("Client {} error: {:?}", peer, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -147,9 +157,9 @@ async fn send_response(
|
|||||||
async fn handle_client(
|
async fn handle_client(
|
||||||
socket: TcpStream,
|
socket: TcpStream,
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
tx: mpsc::Sender<RigRequest>,
|
rigs: Arc<HashMap<String, RigHandle>>,
|
||||||
|
default_rig_id: String,
|
||||||
validator: Arc<SimpleTokenValidator>,
|
validator: Arc<SimpleTokenValidator>,
|
||||||
state_rx: watch::Receiver<RigState>,
|
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let (reader, mut writer) = socket.into_split();
|
let (reader, mut writer) = socket.into_split();
|
||||||
@@ -196,7 +206,9 @@ async fn handle_client(
|
|||||||
error!("Invalid JSON from {}: {} / {:?}", addr, trimmed, e);
|
error!("Invalid JSON from {}: {} / {:?}", addr, trimmed, e);
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: None,
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some(format!("Invalid JSON: {}", e)),
|
error: Some(format!("Invalid JSON: {}", e)),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -207,23 +219,74 @@ async fn handle_client(
|
|||||||
if let Err(err) = validator.as_ref().validate(&envelope.token) {
|
if let Err(err) = validator.as_ref().validate(&envelope.token) {
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: None,
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some(err),
|
error: Some(err),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve rig_id from the envelope (absent = default).
|
||||||
|
let target_rig_id = envelope
|
||||||
|
.rig_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&default_rig_id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// GetRigs: aggregate all rig states and return without hitting any task.
|
||||||
|
if matches!(envelope.cmd, ClientCommand::GetRigs) {
|
||||||
|
let mut entries: Vec<RigEntry> = Vec::new();
|
||||||
|
for handle in rigs.values() {
|
||||||
|
let state = handle.state_rx.borrow().clone();
|
||||||
|
if let Some(snapshot) = state.snapshot() {
|
||||||
|
entries.push(RigEntry {
|
||||||
|
rig_id: handle.rig_id.clone(),
|
||||||
|
state: snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resp = ClientResponse {
|
||||||
|
success: true,
|
||||||
|
rig_id: Some("server".to_string()),
|
||||||
|
state: None,
|
||||||
|
rigs: Some(entries),
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
send_response(&mut writer, &resp).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the target rig handle.
|
||||||
|
let handle = match rigs.get(&target_rig_id) {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
warn!("Unknown rig_id '{}' from {}", target_rig_id, addr);
|
||||||
|
let resp = ClientResponse {
|
||||||
|
success: false,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
|
state: None,
|
||||||
|
rigs: None,
|
||||||
|
error: Some(format!("Unknown rig_id: {}", target_rig_id)),
|
||||||
|
};
|
||||||
|
send_response(&mut writer, &resp).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let rig_cmd = mapping::client_command_to_rig(envelope.cmd);
|
let rig_cmd = mapping::client_command_to_rig(envelope.cmd);
|
||||||
|
|
||||||
// Fast path: serve GetSnapshot directly from the watch channel
|
// Fast path: serve GetSnapshot directly from the watch channel
|
||||||
// so clients get a response even while the rig task is initializing.
|
// so clients get a response even while the rig task is initializing.
|
||||||
if matches!(rig_cmd, RigCommand::GetSnapshot) {
|
if matches!(rig_cmd, RigCommand::GetSnapshot) {
|
||||||
let state = state_rx.borrow().clone();
|
let state = handle.state_rx.borrow().clone();
|
||||||
if let Some(snapshot) = state.snapshot() {
|
if let Some(snapshot) = state.snapshot() {
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: true,
|
success: true,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: Some(snapshot),
|
state: Some(snapshot),
|
||||||
|
rigs: None,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -237,13 +300,15 @@ async fn handle_client(
|
|||||||
respond_to: resp_tx,
|
respond_to: resp_tx,
|
||||||
};
|
};
|
||||||
|
|
||||||
match time::timeout(IO_TIMEOUT, tx.send(req)).await {
|
match time::timeout(IO_TIMEOUT, handle.rig_tx.send(req)).await {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
error!("Failed to send request to rig_task: {:?}", e);
|
error!("Failed to send request to rig_task for '{}': {:?}", target_rig_id, e);
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some("Internal error: rig task not available".into()),
|
error: Some("Internal error: rig task not available".into()),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -252,7 +317,9 @@ async fn handle_client(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some("Internal error: request queue timeout".into()),
|
error: Some("Internal error: request queue timeout".into()),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -267,7 +334,9 @@ async fn handle_client(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some("Request timed out waiting for rig response".into()),
|
error: Some("Request timed out waiting for rig response".into()),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -289,7 +358,9 @@ async fn handle_client(
|
|||||||
Ok(Ok(snapshot)) => {
|
Ok(Ok(snapshot)) => {
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: true,
|
success: true,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: Some(snapshot),
|
state: Some(snapshot),
|
||||||
|
rigs: None,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -297,7 +368,9 @@ async fn handle_client(
|
|||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some(err.message),
|
error: Some(err.message),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -306,7 +379,9 @@ async fn handle_client(
|
|||||||
error!("Rig response oneshot recv error: {:?}", e);
|
error!("Rig response oneshot recv error: {:?}", e);
|
||||||
let resp = ClientResponse {
|
let resp = ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
|
rig_id: Some(target_rig_id.clone()),
|
||||||
state: None,
|
state: None,
|
||||||
|
rigs: None,
|
||||||
error: Some("Internal error waiting for rig response".into()),
|
error: Some("Internal error waiting for rig response".into()),
|
||||||
};
|
};
|
||||||
send_response(&mut writer, &resp).await?;
|
send_response(&mut writer, &resp).await?;
|
||||||
@@ -325,9 +400,12 @@ mod tests {
|
|||||||
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
use trx_core::radio::freq::Band;
|
use trx_core::radio::freq::Band;
|
||||||
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
|
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
|
||||||
|
use trx_core::rig::state::RigState;
|
||||||
|
|
||||||
fn loopback_addr() -> SocketAddr {
|
fn loopback_addr() -> SocketAddr {
|
||||||
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind");
|
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind");
|
||||||
@@ -367,18 +445,30 @@ mod tests {
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_rigs(state: RigState) -> (Arc<HashMap<String, RigHandle>>, String) {
|
||||||
|
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
|
||||||
|
let (state_tx, state_rx) = watch::channel(state);
|
||||||
|
let _state_tx = state_tx;
|
||||||
|
let handle = RigHandle {
|
||||||
|
rig_id: "default".to_string(),
|
||||||
|
rig_tx,
|
||||||
|
state_rx,
|
||||||
|
};
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("default".to_string(), handle);
|
||||||
|
(Arc::new(map), "default".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires TCP bind permissions"]
|
#[ignore = "requires TCP bind permissions"]
|
||||||
async fn listener_rejects_missing_token() {
|
async fn listener_rejects_missing_token() {
|
||||||
let addr = loopback_addr();
|
let addr = loopback_addr();
|
||||||
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
|
let (rigs, default_id) = make_rigs(sample_state());
|
||||||
let (state_tx, state_rx) = watch::channel(sample_state());
|
|
||||||
let _state_tx = state_tx;
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
let mut auth = HashSet::new();
|
let mut auth = HashSet::new();
|
||||||
auth.insert("secret".to_string());
|
auth.insert("secret".to_string());
|
||||||
let handle = tokio::spawn(run_listener(addr, rig_tx, auth, state_rx, shutdown_rx));
|
let handle = tokio::spawn(run_listener(addr, rigs, default_id, auth, shutdown_rx));
|
||||||
|
|
||||||
let stream = TcpStream::connect(addr).await.expect("connect");
|
let stream = TcpStream::connect(addr).await.expect("connect");
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
@@ -406,16 +496,14 @@ mod tests {
|
|||||||
#[ignore = "requires TCP bind permissions"]
|
#[ignore = "requires TCP bind permissions"]
|
||||||
async fn listener_serves_get_state_snapshot() {
|
async fn listener_serves_get_state_snapshot() {
|
||||||
let addr = loopback_addr();
|
let addr = loopback_addr();
|
||||||
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
|
let (rigs, default_id) = make_rigs(sample_state());
|
||||||
let (state_tx, state_rx) = watch::channel(sample_state());
|
|
||||||
let _state_tx = state_tx;
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
let handle = tokio::spawn(run_listener(
|
let handle = tokio::spawn(run_listener(
|
||||||
addr,
|
addr,
|
||||||
rig_tx,
|
rigs,
|
||||||
|
default_id,
|
||||||
HashSet::new(),
|
HashSet::new(),
|
||||||
state_rx,
|
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -437,6 +525,45 @@ mod tests {
|
|||||||
let snapshot = resp.state.expect("snapshot");
|
let snapshot = resp.state.expect("snapshot");
|
||||||
assert_eq!(snapshot.info.model, "Dummy");
|
assert_eq!(snapshot.info.model, "Dummy");
|
||||||
assert_eq!(snapshot.status.freq.hz, 144_300_000);
|
assert_eq!(snapshot.status.freq.hz, 144_300_000);
|
||||||
|
// rig_id should be set in the response
|
||||||
|
assert_eq!(resp.rig_id.as_deref(), Some("default"));
|
||||||
|
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
handle.abort();
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires TCP bind permissions"]
|
||||||
|
async fn listener_routes_unknown_rig_id() {
|
||||||
|
let addr = loopback_addr();
|
||||||
|
let (rigs, default_id) = make_rigs(sample_state());
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(run_listener(
|
||||||
|
addr,
|
||||||
|
rigs,
|
||||||
|
default_id,
|
||||||
|
HashSet::new(),
|
||||||
|
shutdown_rx,
|
||||||
|
));
|
||||||
|
|
||||||
|
let stream = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
writer
|
||||||
|
.write_all(br#"{"rig_id":"nonexistent","cmd":"get_state"}"#)
|
||||||
|
.await
|
||||||
|
.expect("write");
|
||||||
|
writer.write_all(b"\n").await.expect("newline");
|
||||||
|
writer.flush().await.expect("flush");
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await.expect("read");
|
||||||
|
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
|
||||||
|
assert!(!resp.success);
|
||||||
|
assert!(resp.error.as_deref().unwrap_or("").contains("Unknown rig_id"));
|
||||||
|
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
handle.abort();
|
handle.abort();
|
||||||
|
|||||||
+506
-335
@@ -8,12 +8,15 @@ mod config;
|
|||||||
mod error;
|
mod error;
|
||||||
mod listener;
|
mod listener;
|
||||||
mod pskreporter;
|
mod pskreporter;
|
||||||
|
mod rig_handle;
|
||||||
mod rig_task;
|
mod rig_task;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::ptr::NonNull;
|
use std::ptr::NonNull;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -32,7 +35,9 @@ use trx_core::rig::request::RigRequest;
|
|||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
use trx_core::DynResult;
|
use trx_core::DynResult;
|
||||||
|
|
||||||
use config::ServerConfig;
|
use audio::DecoderHistories;
|
||||||
|
use config::{RigInstanceConfig, ServerConfig};
|
||||||
|
use rig_handle::RigHandle;
|
||||||
use trx_decode_log::DecoderLoggers;
|
use trx_decode_log::DecoderLoggers;
|
||||||
|
|
||||||
#[cfg(feature = "soapysdr")]
|
#[cfg(feature = "soapysdr")]
|
||||||
@@ -101,7 +106,7 @@ fn parse_serial_addr(addr: &str) -> DynResult<(String, u32)> {
|
|||||||
Ok((path.to_string(), baud))
|
Ok((path.to_string(), baud))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved configuration after merging config file and CLI arguments.
|
/// Resolved configuration for the first/only rig (legacy single-rig CLI path).
|
||||||
struct ResolvedConfig {
|
struct ResolvedConfig {
|
||||||
rig: String,
|
rig: String,
|
||||||
access: RigAccess,
|
access: RigAccess,
|
||||||
@@ -190,51 +195,35 @@ fn resolve_config(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_rig_task_config(
|
/// Derive a `RigAccess` from a rig instance config's access fields.
|
||||||
resolved: &ResolvedConfig,
|
fn access_from_rig_instance(rig_cfg: &RigInstanceConfig) -> DynResult<RigAccess> {
|
||||||
cfg: &ServerConfig,
|
match rig_cfg.rig.access.access_type.as_deref() {
|
||||||
registry: std::sync::Arc<RegistrationContext>,
|
Some("serial") | None => {
|
||||||
) -> rig_task::RigTaskConfig {
|
let path = rig_cfg
|
||||||
let pskreporter_status = if cfg.pskreporter.enabled {
|
.rig
|
||||||
let has_locator = cfg.pskreporter.receiver_locator.is_some()
|
.access
|
||||||
|| (resolved.latitude.is_some() && resolved.longitude.is_some());
|
.port
|
||||||
if has_locator {
|
.clone()
|
||||||
Some(format!(
|
.unwrap_or_else(|| "/dev/ttyUSB0".to_string());
|
||||||
"Enabled ({}:{})",
|
let baud = rig_cfg.rig.access.baud.unwrap_or(9600);
|
||||||
cfg.pskreporter.host, cfg.pskreporter.port
|
Ok(RigAccess::Serial { path, baud })
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Some(format!(
|
|
||||||
"Enabled but inactive (missing locator source) ({}:{})",
|
|
||||||
cfg.pskreporter.host, cfg.pskreporter.port
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
} else {
|
Some("tcp") => {
|
||||||
Some("Disabled".to_string())
|
let host = rig_cfg.rig.access.host.clone().unwrap_or_default();
|
||||||
};
|
let port = rig_cfg.rig.access.tcp_port.unwrap_or(0);
|
||||||
|
Ok(RigAccess::Tcp {
|
||||||
rig_task::RigTaskConfig {
|
addr: format!("{}:{}", host, port),
|
||||||
registry,
|
})
|
||||||
rig_model: resolved.rig.clone(),
|
}
|
||||||
access: resolved.access.clone(),
|
Some("sdr") => {
|
||||||
polling: AdaptivePolling::new(
|
let args = rig_cfg.rig.access.args.clone().unwrap_or_default();
|
||||||
Duration::from_millis(cfg.behavior.poll_interval_ms),
|
Ok(RigAccess::Sdr { args })
|
||||||
Duration::from_millis(cfg.behavior.poll_interval_tx_ms),
|
}
|
||||||
),
|
Some(other) => Err(format!(
|
||||||
retry: ExponentialBackoff::new(
|
"Unknown access type '{}' for rig '{}'",
|
||||||
cfg.behavior.max_retries.max(1),
|
other, rig_cfg.id
|
||||||
Duration::from_millis(cfg.behavior.retry_base_delay_ms),
|
)
|
||||||
Duration::from_secs(RETRY_MAX_DELAY_SECS),
|
.into()),
|
||||||
),
|
|
||||||
initial_freq_hz: cfg.rig.initial_freq_hz,
|
|
||||||
initial_mode: cfg.rig.initial_mode.clone(),
|
|
||||||
server_callsign: resolved.callsign.clone(),
|
|
||||||
server_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
|
||||||
server_build_date: Some(env!("TRX_SERVER_BUILD_DATE").to_string()),
|
|
||||||
server_latitude: resolved.latitude,
|
|
||||||
server_longitude: resolved.longitude,
|
|
||||||
pskreporter_status,
|
|
||||||
prebuilt_rig: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,13 +260,10 @@ fn parse_rig_mode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `SoapySdrRig` with full channel config from `ServerConfig` and
|
/// Build a `SoapySdrRig` with full channel config from a `RigInstanceConfig`.
|
||||||
/// return both the rig box and a PCM receiver subscribed to its primary channel.
|
|
||||||
///
|
|
||||||
/// Only compiled when the `soapysdr` feature is enabled.
|
|
||||||
#[cfg(feature = "soapysdr")]
|
#[cfg(feature = "soapysdr")]
|
||||||
fn build_sdr_rig(
|
fn build_sdr_rig_from_instance(
|
||||||
cfg: &ServerConfig,
|
rig_cfg: &RigInstanceConfig,
|
||||||
) -> DynResult<(
|
) -> DynResult<(
|
||||||
Box<dyn trx_core::rig::RigCat>,
|
Box<dyn trx_core::rig::RigCat>,
|
||||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||||
@@ -285,14 +271,14 @@ fn build_sdr_rig(
|
|||||||
use trx_core::radio::freq::Freq;
|
use trx_core::radio::freq::Freq;
|
||||||
use trx_core::rig::AudioSource;
|
use trx_core::rig::AudioSource;
|
||||||
|
|
||||||
let args = cfg.rig.access.args.as_deref().unwrap_or("");
|
let args = rig_cfg.rig.access.args.as_deref().unwrap_or("");
|
||||||
let channels: Vec<(f64, trx_core::rig::state::RigMode, u32, usize)> = cfg
|
let channels: Vec<(f64, trx_core::rig::state::RigMode, u32, usize)> = rig_cfg
|
||||||
.sdr
|
.sdr
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
.map(|ch| {
|
.map(|ch| {
|
||||||
let if_hz = (cfg.sdr.center_offset_hz + ch.offset_hz) as f64;
|
let if_hz = (rig_cfg.sdr.center_offset_hz + ch.offset_hz) as f64;
|
||||||
let mode = parse_rig_mode(&ch.mode, &cfg.rig.initial_mode);
|
let mode = parse_rig_mode(&ch.mode, &rig_cfg.rig.initial_mode);
|
||||||
(if_hz, mode, ch.audio_bandwidth_hz, ch.fir_taps)
|
(if_hz, mode, ch.audio_bandwidth_hz, ch.fir_taps)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -300,28 +286,290 @@ fn build_sdr_rig(
|
|||||||
let sdr_rig = trx_backend_soapysdr::SoapySdrRig::new_with_config(
|
let sdr_rig = trx_backend_soapysdr::SoapySdrRig::new_with_config(
|
||||||
args,
|
args,
|
||||||
&channels,
|
&channels,
|
||||||
&cfg.sdr.gain.mode,
|
&rig_cfg.sdr.gain.mode,
|
||||||
cfg.sdr.gain.value,
|
rig_cfg.sdr.gain.value,
|
||||||
cfg.audio.sample_rate,
|
rig_cfg.audio.sample_rate,
|
||||||
cfg.audio.frame_duration_ms,
|
rig_cfg.audio.frame_duration_ms,
|
||||||
Freq {
|
Freq {
|
||||||
hz: cfg.rig.initial_freq_hz,
|
hz: rig_cfg.rig.initial_freq_hz,
|
||||||
},
|
},
|
||||||
cfg.rig.initial_mode.clone(),
|
rig_cfg.rig.initial_mode.clone(),
|
||||||
cfg.sdr.sample_rate,
|
rig_cfg.sdr.sample_rate,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Subscribe to the primary channel's PCM broadcast before consuming the rig.
|
|
||||||
let pcm_rx = sdr_rig.subscribe_pcm();
|
let pcm_rx = sdr_rig.subscribe_pcm();
|
||||||
Ok((Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>, pcm_rx))
|
Ok((Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>, pcm_rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a `RigTaskConfig` for a single rig instance.
|
||||||
|
fn build_rig_task_config(
|
||||||
|
rig_cfg: &RigInstanceConfig,
|
||||||
|
rig_model: String,
|
||||||
|
access: RigAccess,
|
||||||
|
callsign: Option<String>,
|
||||||
|
latitude: Option<f64>,
|
||||||
|
longitude: Option<f64>,
|
||||||
|
registry: Arc<RegistrationContext>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
|
) -> rig_task::RigTaskConfig {
|
||||||
|
let pskreporter_status = if rig_cfg.pskreporter.enabled {
|
||||||
|
let has_locator = rig_cfg.pskreporter.receiver_locator.is_some()
|
||||||
|
|| (latitude.is_some() && longitude.is_some());
|
||||||
|
if has_locator {
|
||||||
|
Some(format!(
|
||||||
|
"Enabled ({}:{})",
|
||||||
|
rig_cfg.pskreporter.host, rig_cfg.pskreporter.port
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some(format!(
|
||||||
|
"Enabled but inactive (missing locator source) ({}:{})",
|
||||||
|
rig_cfg.pskreporter.host, rig_cfg.pskreporter.port
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some("Disabled".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
rig_task::RigTaskConfig {
|
||||||
|
registry,
|
||||||
|
rig_id: rig_cfg.id.clone(),
|
||||||
|
rig_model,
|
||||||
|
access,
|
||||||
|
polling: AdaptivePolling::new(
|
||||||
|
Duration::from_millis(rig_cfg.behavior.poll_interval_ms),
|
||||||
|
Duration::from_millis(rig_cfg.behavior.poll_interval_tx_ms),
|
||||||
|
),
|
||||||
|
retry: ExponentialBackoff::new(
|
||||||
|
rig_cfg.behavior.max_retries.max(1),
|
||||||
|
Duration::from_millis(rig_cfg.behavior.retry_base_delay_ms),
|
||||||
|
Duration::from_secs(RETRY_MAX_DELAY_SECS),
|
||||||
|
),
|
||||||
|
initial_freq_hz: rig_cfg.rig.initial_freq_hz,
|
||||||
|
initial_mode: rig_cfg.rig.initial_mode.clone(),
|
||||||
|
server_callsign: callsign,
|
||||||
|
server_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||||
|
server_build_date: Some(env!("TRX_SERVER_BUILD_DATE").to_string()),
|
||||||
|
server_latitude: latitude,
|
||||||
|
server_longitude: longitude,
|
||||||
|
pskreporter_status,
|
||||||
|
histories,
|
||||||
|
prebuilt_rig: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn all audio-related tasks for one rig instance.
|
||||||
|
///
|
||||||
|
/// `sdr_pcm_rx` carries a live SDR PCM receiver when the rig uses the
|
||||||
|
/// SoapySDR backend; `None` selects the cpal capture path.
|
||||||
|
fn spawn_rig_audio_stack(
|
||||||
|
rig_cfg: &RigInstanceConfig,
|
||||||
|
state_rx: watch::Receiver<RigState>,
|
||||||
|
shutdown_rx: &watch::Receiver<bool>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
|
callsign: Option<String>,
|
||||||
|
latitude: Option<f64>,
|
||||||
|
longitude: Option<f64>,
|
||||||
|
listen_override: Option<IpAddr>,
|
||||||
|
sdr_pcm_rx: Option<broadcast::Receiver<Vec<f32>>>,
|
||||||
|
) -> Vec<JoinHandle<()>> {
|
||||||
|
let mut handles: Vec<JoinHandle<()>> = Vec::new();
|
||||||
|
|
||||||
|
if !rig_cfg.audio.enabled {
|
||||||
|
return handles;
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_listen = SocketAddr::from((
|
||||||
|
listen_override.unwrap_or(rig_cfg.audio.listen),
|
||||||
|
rig_cfg.audio.port,
|
||||||
|
));
|
||||||
|
let stream_info = AudioStreamInfo {
|
||||||
|
sample_rate: rig_cfg.audio.sample_rate,
|
||||||
|
channels: rig_cfg.audio.channels,
|
||||||
|
frame_duration_ms: rig_cfg.audio.frame_duration_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (rx_audio_tx, _) = broadcast::channel::<Bytes>(256);
|
||||||
|
let (tx_audio_tx, tx_audio_rx) = mpsc::channel::<Bytes>(64);
|
||||||
|
|
||||||
|
// PCM tap for server-side decoders
|
||||||
|
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(64);
|
||||||
|
// Decoded messages broadcast
|
||||||
|
let (decode_tx, _) = broadcast::channel::<trx_core::decode::DecodedMessage>(256);
|
||||||
|
|
||||||
|
if rig_cfg.pskreporter.enabled {
|
||||||
|
let cs = callsign.clone().unwrap_or_default();
|
||||||
|
if cs.trim().is_empty() {
|
||||||
|
warn!(
|
||||||
|
"[{}] PSK Reporter enabled but [general].callsign is empty; uplink disabled",
|
||||||
|
rig_cfg.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let pr_cfg = rig_cfg.pskreporter.clone();
|
||||||
|
let pr_state_rx = state_rx.clone();
|
||||||
|
let pr_decode_rx = decode_tx.subscribe();
|
||||||
|
let pr_shutdown_rx = shutdown_rx.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = pskreporter::run_pskreporter_uplink(
|
||||||
|
pr_cfg,
|
||||||
|
cs,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
pr_state_rx,
|
||||||
|
pr_decode_rx
|
||||||
|
) => {}
|
||||||
|
_ = wait_for_shutdown(pr_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rig_cfg.aprsfi.enabled {
|
||||||
|
let cs = callsign.clone().unwrap_or_default();
|
||||||
|
if cs.trim().is_empty() {
|
||||||
|
warn!(
|
||||||
|
"[{}] APRS-IS IGate enabled but [general].callsign is empty; uplink disabled",
|
||||||
|
rig_cfg.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let ai_cfg = rig_cfg.aprsfi.clone();
|
||||||
|
let ai_decode_rx = decode_tx.subscribe();
|
||||||
|
let ai_shutdown_rx = shutdown_rx.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = aprsfi::run_aprsfi_uplink(ai_cfg, cs, ai_decode_rx) => {}
|
||||||
|
_ = wait_for_shutdown(ai_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder_logs = match DecoderLoggers::from_config(&rig_cfg.decode_logs) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[{}] Decoder file logging disabled: {}", rig_cfg.id, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if rig_cfg.audio.rx_enabled {
|
||||||
|
if let Some(mut sdr_rx) = sdr_pcm_rx {
|
||||||
|
// SDR path: the backend pipeline provides demodulated PCM.
|
||||||
|
info!("[{}] using SDR audio source — cpal capture disabled", rig_cfg.id);
|
||||||
|
let pcm_tx_clone = pcm_tx.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match sdr_rx.recv().await {
|
||||||
|
Ok(frame) => {
|
||||||
|
let _ = pcm_tx_clone.send(frame);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!("SDR audio bridge: dropped {} frames", n);
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// cpal path (serial/TCP transceivers)
|
||||||
|
let _capture_thread = audio::spawn_audio_capture(
|
||||||
|
&rig_cfg.audio,
|
||||||
|
rx_audio_tx.clone(),
|
||||||
|
Some(pcm_tx.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn APRS decoder task
|
||||||
|
let aprs_pcm_rx = pcm_tx.subscribe();
|
||||||
|
let aprs_state_rx = state_rx.clone();
|
||||||
|
let aprs_decode_tx = decode_tx.clone();
|
||||||
|
let aprs_sr = rig_cfg.audio.sample_rate;
|
||||||
|
let aprs_ch = rig_cfg.audio.channels;
|
||||||
|
let aprs_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let aprs_logs = decoder_logs.clone();
|
||||||
|
let aprs_histories = histories.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {}
|
||||||
|
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spawn CW decoder task (no histories needed — CW has no persistent history)
|
||||||
|
let cw_pcm_rx = pcm_tx.subscribe();
|
||||||
|
let cw_state_rx = state_rx.clone();
|
||||||
|
let cw_decode_tx = decode_tx.clone();
|
||||||
|
let cw_sr = rig_cfg.audio.sample_rate;
|
||||||
|
let cw_ch = rig_cfg.audio.channels;
|
||||||
|
let cw_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let cw_logs = decoder_logs.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs) => {}
|
||||||
|
_ = wait_for_shutdown(cw_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spawn FT8 decoder task
|
||||||
|
let ft8_pcm_rx = pcm_tx.subscribe();
|
||||||
|
let ft8_state_rx = state_rx.clone();
|
||||||
|
let ft8_decode_tx = decode_tx.clone();
|
||||||
|
let ft8_sr = rig_cfg.audio.sample_rate;
|
||||||
|
let ft8_ch = rig_cfg.audio.channels;
|
||||||
|
let ft8_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let ft8_logs = decoder_logs.clone();
|
||||||
|
let ft8_histories = histories.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {}
|
||||||
|
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spawn WSPR decoder task
|
||||||
|
let wspr_pcm_rx = pcm_tx.subscribe();
|
||||||
|
let wspr_state_rx = state_rx.clone();
|
||||||
|
let wspr_decode_tx = decode_tx.clone();
|
||||||
|
let wspr_sr = rig_cfg.audio.sample_rate;
|
||||||
|
let wspr_ch = rig_cfg.audio.channels;
|
||||||
|
let wspr_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let wspr_logs = decoder_logs.clone();
|
||||||
|
let wspr_histories = histories.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {}
|
||||||
|
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if rig_cfg.audio.tx_enabled {
|
||||||
|
let _playback_thread = audio::spawn_audio_playback(&rig_cfg.audio, tx_audio_rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let audio_histories = histories;
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
if let Err(e) = audio::run_audio_listener(
|
||||||
|
audio_listen,
|
||||||
|
rx_audio_tx,
|
||||||
|
tx_audio_tx,
|
||||||
|
stream_info,
|
||||||
|
decode_tx,
|
||||||
|
audio_shutdown_rx,
|
||||||
|
audio_histories,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Audio listener error: {:?}", e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
handles
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> DynResult<()> {
|
async fn main() -> DynResult<()> {
|
||||||
// Phase 3B: Create bootstrap context for explicit initialization.
|
|
||||||
// This replaces reliance on global mutable state, though currently
|
|
||||||
// built-in backends still register on globals for plugin compatibility.
|
|
||||||
// Full de-globalization would require threading context through rig_task and listener.
|
|
||||||
let mut bootstrap_ctx = RegistrationContext::new();
|
let mut bootstrap_ctx = RegistrationContext::new();
|
||||||
register_builtin_backends_on(&mut bootstrap_ctx);
|
register_builtin_backends_on(&mut bootstrap_ctx);
|
||||||
|
|
||||||
@@ -341,7 +589,7 @@ async fn main() -> DynResult<()> {
|
|||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
||||||
|
|
||||||
// Validate SDR-specific configuration rules (see SDR.md §11).
|
// Validate SDR-specific configuration rules.
|
||||||
let sdr_errors = cfg.validate_sdr();
|
let sdr_errors = cfg.validate_sdr();
|
||||||
if !sdr_errors.is_empty() {
|
if !sdr_errors.is_empty() {
|
||||||
for e in &sdr_errors {
|
for e in &sdr_errors {
|
||||||
@@ -359,98 +607,203 @@ async fn main() -> DynResult<()> {
|
|||||||
info!("Loaded configuration from {}", path.display());
|
info!("Loaded configuration from {}", path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved = resolve_config(&cli, &cfg, &bootstrap_ctx)?;
|
let registry = Arc::new(bootstrap_ctx);
|
||||||
|
|
||||||
match &resolved.access {
|
// --- Resolve the effective rig list ---
|
||||||
RigAccess::Serial { path, baud } => {
|
//
|
||||||
info!(
|
// Legacy path: no [[rigs]] → synthesise from flat fields + CLI overrides.
|
||||||
"Starting trx-server (rig: {}, access: serial {} @ {} baud)",
|
// Multi-rig path: [[rigs]] entries are used as-is; CLI rig/access flags
|
||||||
resolved.rig, path, baud
|
// are ignored (no unambiguous target).
|
||||||
);
|
let mut resolved_rigs = cfg.resolved_rigs();
|
||||||
}
|
|
||||||
RigAccess::Tcp { addr } => {
|
|
||||||
info!(
|
|
||||||
"Starting trx-server (rig: {}, access: tcp {})",
|
|
||||||
resolved.rig, addr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
RigAccess::Sdr { args } => {
|
|
||||||
info!(
|
|
||||||
"Starting trx-server (rig: {}, access: sdr {})",
|
|
||||||
resolved.rig, args
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref cs) = resolved.callsign {
|
let (callsign, latitude, longitude) = if cfg.rigs.is_empty() {
|
||||||
|
// Apply CLI overrides to the first (only) rig.
|
||||||
|
let legacy = resolve_config(&cli, &cfg, ®istry)?;
|
||||||
|
|
||||||
|
let first = resolved_rigs
|
||||||
|
.first_mut()
|
||||||
|
.expect("resolved_rigs always has ≥1 entry");
|
||||||
|
|
||||||
|
first.rig.model = Some(legacy.rig.clone());
|
||||||
|
match &legacy.access {
|
||||||
|
RigAccess::Serial { path, baud } => {
|
||||||
|
first.rig.access.access_type = Some("serial".to_string());
|
||||||
|
first.rig.access.port = Some(path.clone());
|
||||||
|
first.rig.access.baud = Some(*baud);
|
||||||
|
}
|
||||||
|
RigAccess::Tcp { addr } => {
|
||||||
|
first.rig.access.access_type = Some("tcp".to_string());
|
||||||
|
// Split "host:port" back into parts.
|
||||||
|
if let Some(colon) = addr.rfind(':') {
|
||||||
|
first.rig.access.host = Some(addr[..colon].to_string());
|
||||||
|
first.rig.access.tcp_port = addr[colon + 1..].parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RigAccess::Sdr { args } => {
|
||||||
|
first.rig.access.access_type = Some("sdr".to_string());
|
||||||
|
first.rig.access.args = Some(args.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(legacy.callsign, legacy.latitude, legacy.longitude)
|
||||||
|
} else {
|
||||||
|
// Multi-rig path: validate all rig models are registered.
|
||||||
|
for rig_cfg in &resolved_rigs {
|
||||||
|
if let Some(ref model) = rig_cfg.rig.model {
|
||||||
|
let norm = normalize_name(model);
|
||||||
|
if !registry.is_backend_registered(&norm) {
|
||||||
|
return Err(format!(
|
||||||
|
"Unknown rig model '{}' for rig '{}' (available: {})",
|
||||||
|
norm,
|
||||||
|
rig_cfg.id,
|
||||||
|
registry.registered_backends().join(", ")
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let callsign = cli.callsign.clone().or_else(|| cfg.general.callsign.clone());
|
||||||
|
(callsign, cfg.general.latitude, cfg.general.longitude)
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Starting trx-server with {} rig(s): {}",
|
||||||
|
resolved_rigs.len(),
|
||||||
|
resolved_rigs
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.id.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
if let Some(ref cs) = callsign {
|
||||||
info!("Callsign: {}", cs);
|
info!("Callsign: {}", cs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For the SDR access type: build the SoapySdrRig with full channel config
|
|
||||||
// here in main so we can subscribe to its primary-channel PCM sender
|
|
||||||
// before passing the rig to the rig task. The rig task skips its
|
|
||||||
// registry factory when `prebuilt_rig` is set.
|
|
||||||
//
|
|
||||||
// When the `soapysdr` feature is disabled this block is elided and
|
|
||||||
// `sdr_pcm_rx` is always `None`, preserving the cpal path.
|
|
||||||
#[cfg(feature = "soapysdr")]
|
|
||||||
let (sdr_prebuilt_rig, sdr_pcm_rx): (
|
|
||||||
Option<Box<dyn trx_core::rig::RigCat>>,
|
|
||||||
Option<tokio::sync::broadcast::Receiver<Vec<f32>>>,
|
|
||||||
) = if cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
|
||||||
let (rig, pcm_rx) = build_sdr_rig(&cfg)?;
|
|
||||||
(Some(rig), Some(pcm_rx))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(feature = "soapysdr"))]
|
|
||||||
let (sdr_prebuilt_rig, sdr_pcm_rx): (
|
|
||||||
Option<Box<dyn trx_core::rig::RigCat>>,
|
|
||||||
Option<tokio::sync::broadcast::Receiver<Vec<f32>>>,
|
|
||||||
) = (None, None);
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
|
||||||
let mut task_handles: Vec<JoinHandle<()>> = Vec::new();
|
let mut task_handles: Vec<JoinHandle<()>> = Vec::new();
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
let initial_state = RigState::new_with_metadata(
|
|
||||||
resolved.callsign.clone(),
|
|
||||||
Some(env!("CARGO_PKG_VERSION").to_string()),
|
|
||||||
Some(env!("TRX_SERVER_BUILD_DATE").to_string()),
|
|
||||||
resolved.latitude,
|
|
||||||
resolved.longitude,
|
|
||||||
cfg.rig.initial_freq_hz,
|
|
||||||
cfg.rig.initial_mode.clone(),
|
|
||||||
);
|
|
||||||
let mut initial_state = initial_state;
|
|
||||||
initial_state.pskreporter_status = if cfg.pskreporter.enabled {
|
|
||||||
Some(format!(
|
|
||||||
"Enabled ({}:{})",
|
|
||||||
cfg.pskreporter.host, cfg.pskreporter.port
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Some("Disabled".to_string())
|
|
||||||
};
|
|
||||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
|
||||||
// Keep receivers alive so channels don't close prematurely
|
|
||||||
let _state_rx = state_rx;
|
|
||||||
|
|
||||||
let mut rig_task_config =
|
// The first rig id is the default for backward-compat clients that omit rig_id.
|
||||||
build_rig_task_config(&resolved, &cfg, std::sync::Arc::new(bootstrap_ctx));
|
let default_rig_id = resolved_rigs
|
||||||
|
.first()
|
||||||
|
.map(|r| r.id.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
|
||||||
// Pass pre-built SDR rig to the task so it skips the registry factory.
|
let mut rig_handles: HashMap<String, RigHandle> = HashMap::new();
|
||||||
if let Some(prebuilt) = sdr_prebuilt_rig {
|
|
||||||
rig_task_config.prebuilt_rig = Some(prebuilt);
|
for rig_cfg in &resolved_rigs {
|
||||||
|
let rig_model = normalize_name(rig_cfg.rig.model.as_deref().unwrap_or(""));
|
||||||
|
|
||||||
|
let access = access_from_rig_instance(rig_cfg)?;
|
||||||
|
|
||||||
|
match &access {
|
||||||
|
RigAccess::Serial { path, baud } => {
|
||||||
|
info!(
|
||||||
|
"[{}] Starting (rig: {}, access: serial {} @ {} baud)",
|
||||||
|
rig_cfg.id, rig_model, path, baud
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RigAccess::Tcp { addr } => {
|
||||||
|
info!(
|
||||||
|
"[{}] Starting (rig: {}, access: tcp {})",
|
||||||
|
rig_cfg.id, rig_model, addr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RigAccess::Sdr { args } => {
|
||||||
|
info!(
|
||||||
|
"[{}] Starting (rig: {}, access: sdr {})",
|
||||||
|
rig_cfg.id, rig_model, args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SDR rig when applicable.
|
||||||
|
#[cfg(feature = "soapysdr")]
|
||||||
|
let (sdr_prebuilt_rig, sdr_pcm_rx): (
|
||||||
|
Option<Box<dyn trx_core::rig::RigCat>>,
|
||||||
|
Option<broadcast::Receiver<Vec<f32>>>,
|
||||||
|
) = if rig_cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
||||||
|
let (rig, pcm_rx) = build_sdr_rig_from_instance(rig_cfg)?;
|
||||||
|
(Some(rig), Some(pcm_rx))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "soapysdr"))]
|
||||||
|
let (sdr_prebuilt_rig, sdr_pcm_rx): (
|
||||||
|
Option<Box<dyn trx_core::rig::RigCat>>,
|
||||||
|
Option<broadcast::Receiver<Vec<f32>>>,
|
||||||
|
) = (None, None);
|
||||||
|
|
||||||
|
let histories = DecoderHistories::new();
|
||||||
|
|
||||||
|
let (rig_tx, rig_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||||
|
let mut initial_state = RigState::new_with_metadata(
|
||||||
|
callsign.clone(),
|
||||||
|
Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||||
|
Some(env!("TRX_SERVER_BUILD_DATE").to_string()),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
rig_cfg.rig.initial_freq_hz,
|
||||||
|
rig_cfg.rig.initial_mode.clone(),
|
||||||
|
);
|
||||||
|
initial_state.pskreporter_status = if rig_cfg.pskreporter.enabled {
|
||||||
|
Some(format!(
|
||||||
|
"Enabled ({}:{})",
|
||||||
|
rig_cfg.pskreporter.host, rig_cfg.pskreporter.port
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Some("Disabled".to_string())
|
||||||
|
};
|
||||||
|
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||||
|
|
||||||
|
let mut task_config = build_rig_task_config(
|
||||||
|
rig_cfg,
|
||||||
|
rig_model,
|
||||||
|
access,
|
||||||
|
callsign.clone(),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
Arc::clone(®istry),
|
||||||
|
histories.clone(),
|
||||||
|
);
|
||||||
|
if let Some(prebuilt) = sdr_prebuilt_rig {
|
||||||
|
task_config.prebuilt_rig = Some(prebuilt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn rig task.
|
||||||
|
let rig_shutdown_rx = shutdown_rx.clone();
|
||||||
|
task_handles.push(tokio::spawn(async move {
|
||||||
|
if let Err(e) =
|
||||||
|
rig_task::run_rig_task(task_config, rig_rx, state_tx, rig_shutdown_rx).await
|
||||||
|
{
|
||||||
|
error!("Rig task error: {:?}", e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Spawn audio stack.
|
||||||
|
let audio_handles = spawn_rig_audio_stack(
|
||||||
|
rig_cfg,
|
||||||
|
state_rx.clone(),
|
||||||
|
&shutdown_rx,
|
||||||
|
histories.clone(),
|
||||||
|
callsign.clone(),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
cli.listen,
|
||||||
|
sdr_pcm_rx,
|
||||||
|
);
|
||||||
|
task_handles.extend(audio_handles);
|
||||||
|
|
||||||
|
rig_handles.insert(
|
||||||
|
rig_cfg.id.clone(),
|
||||||
|
RigHandle {
|
||||||
|
rig_id: rig_cfg.id.clone(),
|
||||||
|
rig_tx,
|
||||||
|
state_rx,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rig_shutdown_rx = shutdown_rx.clone();
|
// Start JSON TCP listener.
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
if let Err(e) = rig_task::run_rig_task(rig_task_config, rx, state_tx, rig_shutdown_rx).await
|
|
||||||
{
|
|
||||||
error!("Rig task error: {:?}", e);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
if cfg.listen.enabled {
|
if cfg.listen.enabled {
|
||||||
let listen_ip = cli.listen.unwrap_or(cfg.listen.listen);
|
let listen_ip = cli.listen.unwrap_or(cfg.listen.listen);
|
||||||
let listen_port = cli.port.unwrap_or(cfg.listen.port);
|
let listen_port = cli.port.unwrap_or(cfg.listen.port);
|
||||||
@@ -463,15 +816,14 @@ async fn main() -> DynResult<()> {
|
|||||||
.filter(|t| !t.is_empty())
|
.filter(|t| !t.is_empty())
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
let rig_tx = tx.clone();
|
let rigs_arc = Arc::new(rig_handles);
|
||||||
let state_rx_listener = _state_rx.clone();
|
|
||||||
let listener_shutdown_rx = shutdown_rx.clone();
|
let listener_shutdown_rx = shutdown_rx.clone();
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
if let Err(e) = listener::run_listener(
|
if let Err(e) = listener::run_listener(
|
||||||
listen_addr,
|
listen_addr,
|
||||||
rig_tx,
|
rigs_arc,
|
||||||
|
default_rig_id,
|
||||||
auth_tokens,
|
auth_tokens,
|
||||||
state_rx_listener,
|
|
||||||
listener_shutdown_rx,
|
listener_shutdown_rx,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -481,190 +833,9 @@ async fn main() -> DynResult<()> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.audio.enabled {
|
|
||||||
let audio_listen =
|
|
||||||
SocketAddr::from((cli.listen.unwrap_or(cfg.audio.listen), cfg.audio.port));
|
|
||||||
let stream_info = AudioStreamInfo {
|
|
||||||
sample_rate: cfg.audio.sample_rate,
|
|
||||||
channels: cfg.audio.channels,
|
|
||||||
frame_duration_ms: cfg.audio.frame_duration_ms,
|
|
||||||
};
|
|
||||||
|
|
||||||
let (rx_audio_tx, _) = broadcast::channel::<Bytes>(256);
|
|
||||||
let (tx_audio_tx, tx_audio_rx) = mpsc::channel::<Bytes>(64);
|
|
||||||
|
|
||||||
// PCM tap for server-side decoders
|
|
||||||
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(64);
|
|
||||||
// Decoded messages broadcast
|
|
||||||
let (decode_tx, _) = broadcast::channel::<trx_core::decode::DecodedMessage>(256);
|
|
||||||
|
|
||||||
if cfg.pskreporter.enabled {
|
|
||||||
let callsign = resolved.callsign.clone().unwrap_or_default();
|
|
||||||
if callsign.trim().is_empty() {
|
|
||||||
warn!("PSK Reporter enabled but [general].callsign is empty; uplink disabled");
|
|
||||||
} else {
|
|
||||||
let pr_cfg = cfg.pskreporter.clone();
|
|
||||||
let pr_state_rx = _state_rx.clone();
|
|
||||||
let pr_decode_rx = decode_tx.subscribe();
|
|
||||||
let pr_shutdown_rx = shutdown_rx.clone();
|
|
||||||
let latitude = resolved.latitude;
|
|
||||||
let longitude = resolved.longitude;
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = pskreporter::run_pskreporter_uplink(
|
|
||||||
pr_cfg,
|
|
||||||
callsign,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
pr_state_rx,
|
|
||||||
pr_decode_rx
|
|
||||||
) => {}
|
|
||||||
_ = wait_for_shutdown(pr_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.aprsfi.enabled {
|
|
||||||
let callsign = resolved.callsign.clone().unwrap_or_default();
|
|
||||||
if callsign.trim().is_empty() {
|
|
||||||
warn!("APRS-IS IGate enabled but [general].callsign is empty; uplink disabled");
|
|
||||||
} else {
|
|
||||||
let ai_cfg = cfg.aprsfi.clone();
|
|
||||||
let ai_decode_rx = decode_tx.subscribe();
|
|
||||||
let ai_shutdown_rx = shutdown_rx.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = aprsfi::run_aprsfi_uplink(ai_cfg, callsign, ai_decode_rx) => {}
|
|
||||||
_ = wait_for_shutdown(ai_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoder_logs = match DecoderLoggers::from_config(&cfg.decode_logs) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Decoder file logging disabled: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if cfg.audio.rx_enabled {
|
|
||||||
if let Some(mut sdr_rx) = sdr_pcm_rx {
|
|
||||||
// SDR path: the backend pipeline provides demodulated PCM,
|
|
||||||
// so cpal capture is skipped entirely.
|
|
||||||
// The SDR PCM frames are bridged into pcm_tx so the existing
|
|
||||||
// decoder spawn code below receives them unchanged.
|
|
||||||
tracing::info!("using SDR audio source — cpal capture disabled");
|
|
||||||
let pcm_tx_clone = pcm_tx.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match sdr_rx.recv().await {
|
|
||||||
Ok(frame) => {
|
|
||||||
let _ = pcm_tx_clone.send(frame);
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
tracing::warn!("SDR audio bridge: dropped {} frames", n);
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// cpal path (existing serial/TCP transceivers)
|
|
||||||
let _capture_thread = audio::spawn_audio_capture(
|
|
||||||
&cfg.audio,
|
|
||||||
rx_audio_tx.clone(),
|
|
||||||
Some(pcm_tx.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn APRS decoder task
|
|
||||||
let aprs_pcm_rx = pcm_tx.subscribe();
|
|
||||||
let aprs_state_rx = _state_rx.clone();
|
|
||||||
let aprs_decode_tx = decode_tx.clone();
|
|
||||||
let aprs_sr = cfg.audio.sample_rate;
|
|
||||||
let aprs_ch = cfg.audio.channels;
|
|
||||||
let aprs_shutdown_rx = shutdown_rx.clone();
|
|
||||||
let aprs_logs = decoder_logs.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs) => {}
|
|
||||||
_ = wait_for_shutdown(aprs_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Spawn CW decoder task
|
|
||||||
let cw_pcm_rx = pcm_tx.subscribe();
|
|
||||||
let cw_state_rx = _state_rx.clone();
|
|
||||||
let cw_decode_tx = decode_tx.clone();
|
|
||||||
let cw_sr = cfg.audio.sample_rate;
|
|
||||||
let cw_ch = cfg.audio.channels;
|
|
||||||
let cw_shutdown_rx = shutdown_rx.clone();
|
|
||||||
let cw_logs = decoder_logs.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs) => {}
|
|
||||||
_ = wait_for_shutdown(cw_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Spawn FT8 decoder task
|
|
||||||
let ft8_pcm_rx = pcm_tx.subscribe();
|
|
||||||
let ft8_state_rx = _state_rx.clone();
|
|
||||||
let ft8_decode_tx = decode_tx.clone();
|
|
||||||
let ft8_sr = cfg.audio.sample_rate;
|
|
||||||
let ft8_ch = cfg.audio.channels;
|
|
||||||
let ft8_shutdown_rx = shutdown_rx.clone();
|
|
||||||
let ft8_logs = decoder_logs.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs) => {}
|
|
||||||
_ = wait_for_shutdown(ft8_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Spawn WSPR decoder task
|
|
||||||
let wspr_pcm_rx = pcm_tx.subscribe();
|
|
||||||
let wspr_state_rx = _state_rx.clone();
|
|
||||||
let wspr_decode_tx = decode_tx.clone();
|
|
||||||
let wspr_sr = cfg.audio.sample_rate;
|
|
||||||
let wspr_ch = cfg.audio.channels;
|
|
||||||
let wspr_shutdown_rx = shutdown_rx.clone();
|
|
||||||
let wspr_logs = decoder_logs.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
tokio::select! {
|
|
||||||
_ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs) => {}
|
|
||||||
_ = wait_for_shutdown(wspr_shutdown_rx) => {}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if cfg.audio.tx_enabled {
|
|
||||||
let _playback_thread = audio::spawn_audio_playback(&cfg.audio, tx_audio_rx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio_shutdown_rx = shutdown_rx.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
|
||||||
if let Err(e) = audio::run_audio_listener(
|
|
||||||
audio_listen,
|
|
||||||
rx_audio_tx,
|
|
||||||
tx_audio_tx,
|
|
||||||
stream_info,
|
|
||||||
decode_tx,
|
|
||||||
audio_shutdown_rx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Audio listener error: {:?}", e);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
signal::ctrl_c().await?;
|
signal::ctrl_c().await?;
|
||||||
info!("Ctrl+C received, shutting down");
|
info!("Ctrl+C received, shutting down");
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
drop(tx);
|
|
||||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
|
|
||||||
for handle in &task_handles {
|
for handle in &task_handles {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Thin handle giving the listener access to one rig's task and state.
|
||||||
|
|
||||||
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
|
use trx_core::rig::request::RigRequest;
|
||||||
|
use trx_core::rig::state::RigState;
|
||||||
|
|
||||||
|
/// A handle to a single running rig backend.
|
||||||
|
///
|
||||||
|
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
|
||||||
|
/// `Arc<HashMap<String, RigHandle>>` passed to the listener.
|
||||||
|
pub struct RigHandle {
|
||||||
|
/// Stable rig identifier, matches the key in the HashMap.
|
||||||
|
pub rig_id: String,
|
||||||
|
/// Send commands to the rig task.
|
||||||
|
pub rig_tx: mpsc::Sender<RigRequest>,
|
||||||
|
/// Watch the latest rig state for fast GetState/GetRigs responses.
|
||||||
|
pub state_rx: watch::Receiver<RigState>,
|
||||||
|
}
|
||||||
@@ -24,12 +24,14 @@ use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
|
|||||||
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
|
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
|
||||||
use trx_core::{DynResult, RigError, RigResult};
|
use trx_core::{DynResult, RigError, RigResult};
|
||||||
|
|
||||||
use crate::audio;
|
use crate::audio::DecoderHistories;
|
||||||
use crate::error::is_invalid_bcd_error;
|
use crate::error::is_invalid_bcd_error;
|
||||||
|
|
||||||
/// Configuration for the rig task.
|
/// Configuration for the rig task.
|
||||||
pub struct RigTaskConfig {
|
pub struct RigTaskConfig {
|
||||||
pub registry: Arc<RegistrationContext>,
|
pub registry: Arc<RegistrationContext>,
|
||||||
|
/// Stable rig identifier (matches the key in the listener's HashMap).
|
||||||
|
pub rig_id: String,
|
||||||
pub rig_model: String,
|
pub rig_model: String,
|
||||||
pub access: RigAccess,
|
pub access: RigAccess,
|
||||||
pub polling: AdaptivePolling,
|
pub polling: AdaptivePolling,
|
||||||
@@ -42,6 +44,9 @@ pub struct RigTaskConfig {
|
|||||||
pub server_latitude: Option<f64>,
|
pub server_latitude: Option<f64>,
|
||||||
pub server_longitude: Option<f64>,
|
pub server_longitude: Option<f64>,
|
||||||
pub pskreporter_status: Option<String>,
|
pub pskreporter_status: Option<String>,
|
||||||
|
/// Per-rig decoder history store. Used by Reset* commands to clear the
|
||||||
|
/// history and by the audio listener to serve history on connection.
|
||||||
|
pub histories: Arc<DecoderHistories>,
|
||||||
/// Pre-built rig backend. When `Some`, the registry factory is skipped.
|
/// Pre-built rig backend. When `Some`, the registry factory is skipped.
|
||||||
/// Used by the SDR path in `main.rs` to pass a fully-configured
|
/// Used by the SDR path in `main.rs` to pass a fully-configured
|
||||||
/// `SoapySdrRig` (built with channel config) without duplicating the
|
/// `SoapySdrRig` (built with channel config) without duplicating the
|
||||||
@@ -55,6 +60,7 @@ impl Default for RigTaskConfig {
|
|||||||
trx_backend::register_builtin_backends_on(&mut registry);
|
trx_backend::register_builtin_backends_on(&mut registry);
|
||||||
Self {
|
Self {
|
||||||
registry: Arc::new(registry),
|
registry: Arc::new(registry),
|
||||||
|
rig_id: "default".to_string(),
|
||||||
rig_model: "ft817".to_string(),
|
rig_model: "ft817".to_string(),
|
||||||
access: RigAccess::Serial {
|
access: RigAccess::Serial {
|
||||||
path: "/dev/ttyUSB0".to_string(),
|
path: "/dev/ttyUSB0".to_string(),
|
||||||
@@ -70,6 +76,7 @@ impl Default for RigTaskConfig {
|
|||||||
server_latitude: None,
|
server_latitude: None,
|
||||||
server_longitude: None,
|
server_longitude: None,
|
||||||
pskreporter_status: None,
|
pskreporter_status: None,
|
||||||
|
histories: DecoderHistories::new(),
|
||||||
prebuilt_rig: None,
|
prebuilt_rig: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +100,8 @@ pub async fn run_rig_task(
|
|||||||
state_tx: watch::Sender<RigState>,
|
state_tx: watch::Sender<RigState>,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> DynResult<()> {
|
) -> DynResult<()> {
|
||||||
info!("Opening rig backend {}", config.rig_model);
|
let histories = config.histories.clone();
|
||||||
|
info!("[{}] Opening rig backend {}", config.rig_id, config.rig_model);
|
||||||
match &config.access {
|
match &config.access {
|
||||||
RigAccess::Serial { path, baud } => info!("Serial: {} @ {} baud", path, baud),
|
RigAccess::Serial { path, baud } => info!("Serial: {} @ {} baud", path, baud),
|
||||||
RigAccess::Tcp { addr } => info!("TCP CAT: {}", addr),
|
RigAccess::Tcp { addr } => info!("TCP CAT: {}", addr),
|
||||||
@@ -317,6 +325,7 @@ pub async fn run_rig_task(
|
|||||||
last_power_on: &mut last_power_on,
|
last_power_on: &mut last_power_on,
|
||||||
state_tx: &state_tx,
|
state_tx: &state_tx,
|
||||||
retry,
|
retry,
|
||||||
|
histories: &histories,
|
||||||
};
|
};
|
||||||
let result = process_command(cmd, &mut cmd_ctx).await;
|
let result = process_command(cmd, &mut cmd_ctx).await;
|
||||||
|
|
||||||
@@ -347,6 +356,7 @@ struct CommandExecContext<'a> {
|
|||||||
last_power_on: &'a mut Option<Instant>,
|
last_power_on: &'a mut Option<Instant>,
|
||||||
state_tx: &'a watch::Sender<RigState>,
|
state_tx: &'a watch::Sender<RigState>,
|
||||||
retry: &'a ExponentialBackoff,
|
retry: &'a ExponentialBackoff,
|
||||||
|
histories: &'a Arc<DecoderHistories>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_command(
|
async fn process_command(
|
||||||
@@ -393,7 +403,7 @@ async fn process_command(
|
|||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::ResetAprsDecoder => {
|
RigCommand::ResetAprsDecoder => {
|
||||||
audio::clear_aprs_history();
|
ctx.histories.clear_aprs_history();
|
||||||
ctx.state.aprs_decode_reset_seq += 1;
|
ctx.state.aprs_decode_reset_seq += 1;
|
||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
@@ -404,13 +414,13 @@ async fn process_command(
|
|||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::ResetFt8Decoder => {
|
RigCommand::ResetFt8Decoder => {
|
||||||
audio::clear_ft8_history();
|
ctx.histories.clear_ft8_history();
|
||||||
ctx.state.ft8_decode_reset_seq += 1;
|
ctx.state.ft8_decode_reset_seq += 1;
|
||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::ResetWsprDecoder => {
|
RigCommand::ResetWsprDecoder => {
|
||||||
audio::clear_wspr_history();
|
ctx.histories.clear_wspr_history();
|
||||||
ctx.state.wspr_decode_reset_seq += 1;
|
ctx.state.wspr_decode_reset_seq += 1;
|
||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
|
|||||||
Reference in New Issue
Block a user