Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-05-17 23:25:14 +02:00
commit ba48de2d30
237 changed files with 105505 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-protocol"
version.workspace = true
edition = "2021"
[features]
default = []
ft2 = []
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
trx-core = { path = "../trx-core" }
+223
View File
@@ -0,0 +1,223 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Authorization and token handling utilities.
use std::collections::HashSet;
/// Strip the "Bearer " prefix from a token string (case-insensitive).
///
/// If the string starts with "Bearer " (ignoring case), returns the remainder.
/// Otherwise returns the original trimmed string.
pub fn strip_bearer(value: &str) -> &str {
let trimmed = value.trim();
let prefix = "bearer ";
if trimmed.len() >= prefix.len() && trimmed[..prefix.len()].eq_ignore_ascii_case(prefix) {
&trimmed[prefix.len()..]
} else {
trimmed
}
}
/// Trait for validating authorization tokens.
pub trait TokenValidator {
/// Validate a token. Returns Ok(()) if valid, Err(String) with error message if invalid.
fn validate(&self, token: &Option<String>) -> Result<(), String>;
}
/// Simple token validator using a HashSet of valid tokens.
pub struct SimpleTokenValidator {
tokens: HashSet<String>,
}
impl SimpleTokenValidator {
/// Create a new SimpleTokenValidator with a set of valid tokens.
pub fn new(tokens: HashSet<String>) -> Self {
SimpleTokenValidator { tokens }
}
/// Create a new SimpleTokenValidator from a vector of tokens.
pub fn from_vec(tokens: Vec<String>) -> Self {
SimpleTokenValidator {
tokens: tokens.into_iter().collect(),
}
}
/// Check if the validator has any tokens configured.
pub fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
}
impl TokenValidator for SimpleTokenValidator {
fn validate(&self, token: &Option<String>) -> Result<(), String> {
// No auth required if no tokens configured
if self.tokens.is_empty() {
return Ok(());
}
let Some(token) = token.as_ref() else {
return Err("missing authorization token".into());
};
let candidate = strip_bearer(token);
if self.tokens.contains(candidate) {
return Ok(());
}
Err("invalid authorization token".into())
}
}
/// No-op token validator that always accepts all tokens.
///
/// Use this when authentication is disabled.
pub struct NoAuthValidator;
impl TokenValidator for NoAuthValidator {
fn validate(&self, _token: &Option<String>) -> Result<(), String> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_bearer_with_prefix() {
assert_eq!(strip_bearer("Bearer abc123"), "abc123");
}
#[test]
fn test_strip_bearer_lowercase() {
assert_eq!(strip_bearer("bearer xyz789"), "xyz789");
}
#[test]
fn test_strip_bearer_mixed_case() {
assert_eq!(strip_bearer("BeArEr test123"), "test123");
}
#[test]
fn test_strip_bearer_without_prefix() {
assert_eq!(strip_bearer("abc123"), "abc123");
}
#[test]
fn test_strip_bearer_with_whitespace() {
assert_eq!(strip_bearer(" Bearer token "), "token");
}
#[test]
fn test_strip_bearer_empty() {
assert_eq!(strip_bearer(""), "");
}
#[test]
fn test_strip_bearer_only_prefix() {
// "bearer " is exactly the prefix with nothing after it
// trim() preserves it as "bearer " (7 chars including space)
// After stripping "bearer " (7 chars), nothing is left
// But trim also removes the trailing space, so we get "bearer"
// which is 6 chars, less than the 7-char prefix, so it doesn't strip
assert_eq!(strip_bearer("bearer "), "bearer");
}
#[test]
fn test_simple_token_validator_with_valid_token() {
let mut tokens = HashSet::new();
tokens.insert("token123".to_string());
let validator = SimpleTokenValidator::new(tokens);
let result = validator.validate(&Some("token123".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_simple_token_validator_with_bearer_prefix() {
let mut tokens = HashSet::new();
tokens.insert("token123".to_string());
let validator = SimpleTokenValidator::new(tokens);
let result = validator.validate(&Some("Bearer token123".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_simple_token_validator_with_invalid_token() {
let mut tokens = HashSet::new();
tokens.insert("token123".to_string());
let validator = SimpleTokenValidator::new(tokens);
let result = validator.validate(&Some("wrongtoken".to_string()));
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "invalid authorization token");
}
#[test]
fn test_simple_token_validator_with_missing_token() {
let mut tokens = HashSet::new();
tokens.insert("token123".to_string());
let validator = SimpleTokenValidator::new(tokens);
let result = validator.validate(&None);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "missing authorization token");
}
#[test]
fn test_simple_token_validator_no_auth_required() {
let tokens = HashSet::new();
let validator = SimpleTokenValidator::new(tokens);
// No token required when validator is empty
let result = validator.validate(&None);
assert!(result.is_ok());
let result = validator.validate(&Some("anytoken".to_string()));
assert!(result.is_ok());
}
#[test]
fn test_simple_token_validator_from_vec() {
let tokens = vec!["token1".to_string(), "token2".to_string()];
let validator = SimpleTokenValidator::from_vec(tokens);
assert!(validator.validate(&Some("token1".to_string())).is_ok());
assert!(validator.validate(&Some("token2".to_string())).is_ok());
assert!(validator.validate(&Some("token3".to_string())).is_err());
}
#[test]
fn test_simple_token_validator_is_empty() {
let empty = SimpleTokenValidator::new(HashSet::new());
assert!(empty.is_empty());
let mut tokens = HashSet::new();
tokens.insert("token".to_string());
let not_empty = SimpleTokenValidator::new(tokens);
assert!(!not_empty.is_empty());
}
#[test]
fn test_no_auth_validator_with_no_token() {
let validator = NoAuthValidator;
assert!(validator.validate(&None).is_ok());
}
#[test]
fn test_no_auth_validator_with_token() {
let validator = NoAuthValidator;
assert!(validator.validate(&Some("anytoken".to_string())).is_ok());
}
#[test]
fn test_no_auth_validator_with_bearer_token() {
let validator = NoAuthValidator;
assert!(validator
.validate(&Some("Bearer secret123".to_string()))
.is_ok());
}
}
+481
View File
@@ -0,0 +1,481 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Codec utilities for parsing and formatting modes and envelopes.
use std::borrow::Cow;
use serde_json;
use crate::types::{ClientCommand, ClientEnvelope};
use trx_core::rig::state::RigMode;
/// Parse a mode string into a RigMode.
///
/// Handles LSB, USB, CW, CWR, AM, FM, WFM, AIS, VDES, DIG, DIGI, PKT, PACKET.
/// Falls back to Other(string) for unknown modes.
pub fn parse_mode(s: &str) -> RigMode {
match s.to_uppercase().as_str() {
"LSB" => RigMode::LSB,
"USB" => RigMode::USB,
"CW" => RigMode::CW,
"CWR" => RigMode::CWR,
"AM" => RigMode::AM,
"SAM" | "AMC-QUAM" | "AMC_QUAM" | "AMC" => RigMode::SAM,
"FM" => RigMode::FM,
"WFM" => RigMode::WFM,
"AIS" => RigMode::AIS,
"VDES" => RigMode::VDES,
"DIG" | "DIGI" => RigMode::DIG,
"PKT" | "PACKET" => RigMode::PKT,
other => RigMode::Other(other.to_string()),
}
}
/// Convert a RigMode back to its string representation.
///
/// This is the inverse of parse_mode. Standard modes return a borrowed
/// `&'static str` (zero allocation), while `Other` variants return the
/// owned inner string.
pub fn mode_to_string(mode: &RigMode) -> Cow<'static, str> {
match mode {
RigMode::LSB => Cow::Borrowed("LSB"),
RigMode::USB => Cow::Borrowed("USB"),
RigMode::CW => Cow::Borrowed("CW"),
RigMode::CWR => Cow::Borrowed("CWR"),
RigMode::AM => Cow::Borrowed("AM"),
RigMode::SAM => Cow::Borrowed("SAM"),
RigMode::FM => Cow::Borrowed("FM"),
RigMode::WFM => Cow::Borrowed("WFM"),
RigMode::AIS => Cow::Borrowed("AIS"),
RigMode::VDES => Cow::Borrowed("VDES"),
RigMode::DIG => Cow::Borrowed("DIG"),
RigMode::PKT => Cow::Borrowed("PKT"),
RigMode::Other(s) => Cow::Owned(s.clone()),
}
}
/// Parse a JSON string into a ClientEnvelope.
///
/// First tries to parse as a full ClientEnvelope.
/// If that fails, tries to parse as a bare ClientCommand and wraps it with token: None.
/// Unknown command names are reported as errors rather than causing a parse failure,
/// enabling forward compatibility when newer clients connect to older servers.
pub fn parse_envelope(input: &str) -> Result<ClientEnvelope, serde_json::Error> {
match serde_json::from_str::<ClientEnvelope>(input) {
Ok(envelope) => Ok(envelope),
Err(envelope_err) => {
// Try bare command fallback.
match serde_json::from_str::<ClientCommand>(input) {
Ok(cmd) => Ok(ClientEnvelope {
token: None,
rig_id: None,
protocol_version: None,
cmd,
}),
Err(_) => {
// Check if the input is valid JSON with an unrecognised "cmd" value.
// Return the original envelope error for truly malformed input.
if let Ok(val) = serde_json::from_str::<serde_json::Value>(input) {
if val.get("cmd").and_then(|c| c.as_str()).is_some() {
return Err(envelope_err);
}
}
Err(envelope_err)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_mode_standard_modes() {
assert_eq!(parse_mode("LSB"), RigMode::LSB);
assert_eq!(parse_mode("USB"), RigMode::USB);
assert_eq!(parse_mode("CW"), RigMode::CW);
assert_eq!(parse_mode("CWR"), RigMode::CWR);
assert_eq!(parse_mode("AM"), RigMode::AM);
assert_eq!(parse_mode("SAM"), RigMode::SAM);
assert_eq!(parse_mode("AMC-QUAM"), RigMode::SAM);
assert_eq!(parse_mode("FM"), RigMode::FM);
assert_eq!(parse_mode("WFM"), RigMode::WFM);
assert_eq!(parse_mode("AIS"), RigMode::AIS);
assert_eq!(parse_mode("VDES"), RigMode::VDES);
}
#[test]
fn test_parse_mode_aliases() {
assert_eq!(parse_mode("DIG"), RigMode::DIG);
assert_eq!(parse_mode("DIGI"), RigMode::DIG);
assert_eq!(parse_mode("PKT"), RigMode::PKT);
assert_eq!(parse_mode("PACKET"), RigMode::PKT);
}
#[test]
fn test_parse_mode_case_insensitive() {
assert_eq!(parse_mode("lsb"), RigMode::LSB);
assert_eq!(parse_mode("Usb"), RigMode::USB);
assert_eq!(parse_mode("cw"), RigMode::CW);
}
#[test]
fn test_parse_mode_unknown() {
if let RigMode::Other(s) = parse_mode("UNKNOWN") {
assert_eq!(s, "UNKNOWN");
} else {
panic!("Expected Other variant");
}
}
#[test]
fn test_parse_mode_empty() {
if let RigMode::Other(s) = parse_mode("") {
assert_eq!(s, "");
} else {
panic!("Expected Other variant");
}
}
#[test]
fn test_mode_to_string_standard_modes() {
assert_eq!(mode_to_string(&RigMode::LSB), "LSB");
assert_eq!(mode_to_string(&RigMode::USB), "USB");
assert_eq!(mode_to_string(&RigMode::CW), "CW");
assert_eq!(mode_to_string(&RigMode::CWR), "CWR");
assert_eq!(mode_to_string(&RigMode::AM), "AM");
assert_eq!(mode_to_string(&RigMode::SAM), "SAM");
assert_eq!(mode_to_string(&RigMode::FM), "FM");
assert_eq!(mode_to_string(&RigMode::WFM), "WFM");
assert_eq!(mode_to_string(&RigMode::AIS), "AIS");
assert_eq!(mode_to_string(&RigMode::VDES), "VDES");
assert_eq!(mode_to_string(&RigMode::DIG), "DIG");
assert_eq!(mode_to_string(&RigMode::PKT), "PKT");
}
#[test]
fn test_mode_to_string_other() {
assert_eq!(mode_to_string(&RigMode::Other("XYZ".to_string())), "XYZ");
}
#[test]
fn test_mode_round_trip() {
let modes = vec![
RigMode::LSB,
RigMode::USB,
RigMode::CW,
RigMode::CWR,
RigMode::AM,
RigMode::SAM,
RigMode::FM,
RigMode::WFM,
RigMode::AIS,
RigMode::VDES,
RigMode::DIG,
RigMode::PKT,
];
for mode in modes {
let s = mode_to_string(&mode);
let parsed = parse_mode(&s);
assert_eq!(parsed, mode, "Round trip failed for {:?}", mode);
}
}
#[test]
fn test_parse_envelope_full_envelope() {
let json = r#"{"token":"abc123","cmd":"get_state"}"#;
let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.token, Some("abc123".to_string()));
assert!(matches!(envelope.cmd, ClientCommand::GetState));
}
#[test]
fn test_parse_envelope_bare_command() {
let json = r#"{"cmd":"get_state"}"#;
let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.token, None);
assert!(matches!(envelope.cmd, ClientCommand::GetState));
}
#[test]
fn test_parse_envelope_bare_command_with_params() {
let json = r#"{"cmd":"set_freq","freq_hz":14100000}"#;
let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.token, None);
if let ClientCommand::SetFreq { freq_hz } = envelope.cmd {
assert_eq!(freq_hz, 14100000);
} else {
panic!("Expected SetFreq variant");
}
}
#[test]
fn test_parse_envelope_invalid_json() {
let json = "not valid json";
let result = parse_envelope(json);
assert!(result.is_err());
}
#[test]
fn test_parse_envelope_invalid_command() {
let json = r#"{"cmd":"invalid_command"}"#;
let result = parse_envelope(json);
assert!(result.is_err());
}
#[test]
fn test_parse_envelope_with_bearer_token() {
let json = r#"{"token":"Bearer abc123xyz","cmd":"get_state"}"#;
let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.token, Some("Bearer abc123xyz".to_string()));
}
// --- MR-09: multi-rig protocol tests ---
#[test]
fn test_parse_envelope_absent_rig_id_defaults_to_none() {
let json = r#"{"cmd":"get_state"}"#;
let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.rig_id, None, "absent rig_id should parse as None");
}
#[test]
fn test_parse_envelope_with_rig_id() {
let json = r#"{"rig_id":"hf","cmd":"get_state"}"#;
let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.rig_id, Some("hf".to_string()));
assert!(matches!(envelope.cmd, ClientCommand::GetState));
}
#[test]
fn test_parse_envelope_get_rigs_command() {
let json = r#"{"cmd":"get_rigs"}"#;
let envelope = parse_envelope(json).unwrap();
assert!(matches!(envelope.cmd, ClientCommand::GetRigs));
assert_eq!(envelope.rig_id, None);
}
#[test]
fn test_parse_envelope_get_rigs_with_rig_id_ignored() {
// rig_id is parsed and available even though GetRigs is intercepted
// before routing — the listener should ignore it for this command.
let json = r#"{"rig_id":"sdr","cmd":"get_rigs"}"#;
let envelope = parse_envelope(json).unwrap();
assert!(matches!(envelope.cmd, ClientCommand::GetRigs));
assert_eq!(envelope.rig_id, Some("sdr".to_string()));
}
#[test]
fn test_client_response_rig_id_roundtrip() {
use crate::types::ClientResponse;
let resp = ClientResponse {
success: true,
rig_id: Some("hf".to_string()),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
error: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains(r#""rig_id":"hf""#));
let decoded: ClientResponse = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.rig_id, Some("hf".to_string()));
}
#[test]
fn test_client_response_omits_rig_id_when_none() {
use crate::types::ClientResponse;
let resp = ClientResponse {
success: false,
rig_id: None,
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
error: Some("bad".to_string()),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(
!json.contains("rig_id"),
"rig_id=None should be omitted from JSON"
);
}
#[test]
fn test_client_response_omits_rigs_when_none() {
use crate::types::ClientResponse;
let resp = ClientResponse {
success: true,
rig_id: Some("server".to_string()),
protocol_version: None,
state: None,
rigs: None,
sat_passes: None,
error: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(
!json.contains("\"rigs\""),
"rigs=None should be omitted from JSON"
);
}
// --- UC-09: filter field serialization tests ---
#[test]
fn filter_field_included_when_some() {
use trx_core::rig::state::RigSnapshot;
use trx_core::RigFilterState;
let snap_json = serde_json::to_string(&RigSnapshot {
filter: Some(RigFilterState {
bandwidth_hz: 3000,
cw_center_hz: 700,
sdr_gain_db: Some(12.0),
sdr_lna_gain_db: None,
sdr_agc_enabled: None,
sdr_squelch_enabled: None,
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
wfm_deemphasis_us: 75,
wfm_stereo: true,
wfm_stereo_detected: false,
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
wfm_cci: 0,
wfm_aci: 0,
sam_stereo_width: 1.0,
sam_carrier_sync: true,
}),
..minimal_snapshot()
})
.unwrap();
assert!(
snap_json.contains("\"filter\""),
"filter=Some should be serialized"
);
assert!(snap_json.contains("\"bandwidth_hz\":3000"));
}
#[test]
fn filter_field_omitted_when_none() {
use trx_core::rig::state::RigSnapshot;
let snap_json = serde_json::to_string(&RigSnapshot {
filter: None,
..minimal_snapshot()
})
.unwrap();
assert!(
!snap_json.contains("\"filter\""),
"filter=None should be omitted from JSON"
);
}
#[test]
fn filter_field_roundtrips() {
use trx_core::rig::state::RigSnapshot;
use trx_core::RigFilterState;
let orig = RigSnapshot {
filter: Some(RigFilterState {
bandwidth_hz: 12000,
cw_center_hz: 700,
sdr_gain_db: Some(18.0),
sdr_lna_gain_db: None,
sdr_agc_enabled: None,
sdr_squelch_enabled: None,
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
wfm_deemphasis_us: 50,
wfm_stereo: true,
wfm_stereo_detected: true,
wfm_denoise: trx_core::WfmDenoiseLevel::Auto,
wfm_cci: 12,
wfm_aci: 45,
sam_stereo_width: 0.5,
sam_carrier_sync: false,
}),
..minimal_snapshot()
};
let json = serde_json::to_string(&orig).unwrap();
let decoded: RigSnapshot = serde_json::from_str(&json).unwrap();
let f = decoded.filter.expect("filter should round-trip");
assert_eq!(f.bandwidth_hz, 12000);
assert_eq!(f.sdr_gain_db, Some(18.0));
assert_eq!(f.wfm_deemphasis_us, 50);
assert!(f.wfm_stereo_detected);
assert_eq!(f.wfm_cci, 12);
assert_eq!(f.wfm_aci, 45);
assert_eq!(f.sam_stereo_width, 0.5);
assert!(!f.sam_carrier_sync);
}
fn minimal_snapshot() -> trx_core::rig::state::RigSnapshot {
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::state::{RigMode, RigSnapshot};
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo, RigStatus};
RigSnapshot {
info: RigInfo {
manufacturer: "Test".to_string(),
model: "Mock".to_string(),
revision: "1".to_string(),
capabilities: RigCapabilities {
min_freq_step_hz: 1,
supported_bands: vec![Band {
low_hz: 14_000_000,
high_hz: 14_350_000,
tx_allowed: true,
}],
supported_modes: vec![RigMode::USB],
num_vfos: 1,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: true,
signal_meter: true,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:1234".to_string(),
},
},
status: RigStatus {
freq: Freq { hz: 14_074_000 },
mode: RigMode::USB,
tx_en: false,
vfo: None,
tx: None,
rx: None,
lock: None,
},
band: None,
enabled: None,
initialized: true,
server_callsign: None,
server_version: None,
server_build_date: None,
server_latitude: None,
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
decoders: trx_core::DecoderConfig::default(),
cw_auto: false,
cw_wpm: 0,
cw_tone_hz: 0,
filter: None,
spectrum: None,
vchan_rds: None,
}
}
}
+248
View File
@@ -0,0 +1,248 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Centralised decoder registry.
//!
//! Every decoder supported by trx-rs is described exactly once here.
//! Backend, frontend, scheduler, and background-decode code all derive
//! their decoder knowledge from [`DECODER_REGISTRY`].
use serde::Serialize;
// ============================================================================
// Types
// ============================================================================
/// How a decoder is activated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DecoderActivation {
/// Automatically active when the rig mode matches.
ModeBound,
/// User-controlled toggle; only runs in `active_modes`.
Toggle,
}
/// Static descriptor for a single decoder.
#[derive(Debug, Clone, Serialize)]
pub struct DecoderDescriptor {
/// Machine identifier, e.g. `"ft8"`, `"aprs"`.
pub id: &'static str,
/// Human-readable label, e.g. `"FT8"`, `"APRS"`.
pub label: &'static str,
/// How the decoder is activated.
pub activation: DecoderActivation,
/// Rig modes where this decoder operates (upper-case).
pub active_modes: &'static [&'static str],
/// Whether the decoder can run on SDR virtual channels
/// (background-decode / scheduler).
pub background_decode: bool,
/// Whether this decoder should appear in bookmark forms.
pub bookmark_selectable: bool,
}
// ============================================================================
// Registry
// ============================================================================
pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
// -- Mode-bound decoders (auto-active when mode matches) -----------------
DecoderDescriptor {
id: "ais",
label: "AIS",
activation: DecoderActivation::ModeBound,
active_modes: &["AIS"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "aprs",
label: "APRS",
activation: DecoderActivation::ModeBound,
active_modes: &["PKT"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "vdes",
label: "VDES",
activation: DecoderActivation::ModeBound,
active_modes: &["VDES"],
background_decode: false,
bookmark_selectable: false,
},
DecoderDescriptor {
id: "cw",
label: "CW",
activation: DecoderActivation::ModeBound,
active_modes: &["CW", "CWR"],
background_decode: false,
bookmark_selectable: false,
},
// -- Toggle-gated decoders (user enables/disables) -----------------------
DecoderDescriptor {
id: "ft8",
label: "FT8",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "ft4",
label: "FT4",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
#[cfg(feature = "ft2")]
DecoderDescriptor {
id: "ft2",
label: "FT2",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "wspr",
label: "WSPR",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "hf-aprs",
label: "HF APRS",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "lrpt",
label: "Meteor LRPT",
activation: DecoderActivation::Toggle,
active_modes: &["FM"],
background_decode: false,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "wefax",
label: "WEFAX",
activation: DecoderActivation::Toggle,
active_modes: &["USB", "LSB", "AM", "DIG"],
background_decode: false,
bookmark_selectable: true,
},
];
// ============================================================================
// Helpers
// ============================================================================
/// Resolve a bookmark's effective decoder kinds.
///
/// If `explicit_decoders` is non-empty, filters them to known IDs (optionally
/// restricting to background-capable decoders). Otherwise infers decoders
/// from the bookmark `mode` using mode-bound entries in the registry.
pub fn resolve_bookmark_decoders(
explicit_decoders: &[String],
mode: &str,
background_only: bool,
) -> Vec<String> {
let from_explicit: Vec<String> = explicit_decoders
.iter()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| {
DECODER_REGISTRY
.iter()
.any(|d| d.id == s.as_str() && (!background_only || d.background_decode))
})
.fold(Vec::new(), |mut acc, s| {
if !acc.contains(&s) {
acc.push(s);
}
acc
});
if !from_explicit.is_empty() {
return from_explicit;
}
// Fall back: infer from mode via mode-bound decoders.
let mode_upper = mode.trim().to_ascii_uppercase();
DECODER_REGISTRY
.iter()
.filter(|d| {
d.activation == DecoderActivation::ModeBound
&& (!background_only || d.background_decode)
&& d.active_modes.contains(&mode_upper.as_str())
})
.map(|d| d.id.to_string())
.collect()
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_decoders_filtered() {
let result =
resolve_bookmark_decoders(&["ft8".into(), "bogus".into(), "ft4".into()], "USB", false);
assert_eq!(result, vec!["ft8", "ft4"]);
}
#[test]
fn explicit_decoders_deduped() {
let result = resolve_bookmark_decoders(&["ft8".into(), "FT8".into()], "USB", false);
assert_eq!(result, vec!["ft8"]);
}
#[test]
fn mode_fallback_ais() {
let result = resolve_bookmark_decoders(&[], "AIS", false);
assert_eq!(result, vec!["ais"]);
}
#[test]
fn mode_fallback_pkt() {
let result = resolve_bookmark_decoders(&[], "PKT", false);
assert_eq!(result, vec!["aprs"]);
}
#[test]
fn mode_fallback_unknown() {
let result = resolve_bookmark_decoders(&[], "USB", false);
assert!(result.is_empty());
}
#[test]
fn background_only_filters_lrpt() {
let result = resolve_bookmark_decoders(&["lrpt".into(), "ft8".into()], "DIG", true);
assert_eq!(result, vec!["ft8"]);
}
#[test]
fn background_only_mode_fallback_excludes_cw() {
// CW is mode-bound but not background_decode capable.
let result = resolve_bookmark_decoders(&[], "CW", true);
assert!(result.is_empty());
}
#[test]
fn registry_ids_unique() {
let mut seen = std::collections::HashSet::new();
for d in DECODER_REGISTRY {
assert!(seen.insert(d.id), "duplicate decoder id: {}", d.id);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Protocol conversion utilities for trx-rs.
//!
//! This crate provides centralized utilities for converting between client and rig protocols,
//! handling authentication tokens, and parsing mode strings.
pub mod auth;
pub mod codec;
pub mod decoders;
pub mod mapping;
pub mod types;
// Re-export commonly used items
pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
+711
View File
@@ -0,0 +1,711 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Bidirectional command mapping between ClientCommand and RigCommand.
use trx_core::radio::freq::Freq;
use trx_core::rig::command::RigCommand;
use crate::codec::{mode_to_string, parse_mode};
use crate::types::ClientCommand;
/// Generates `client_command_to_rig` and `rig_command_to_client` from a
/// single definition table, eliminating the mechanical duplication of
/// mapping every variant by hand.
///
/// Supported row forms (each section is introduced by a keyword):
///
/// - **`client_only:`** `Name, ...;`
/// Variants that exist only in `ClientCommand` with no `RigCommand`
/// counterpart. `client_command_to_rig` panics if called with one.
///
/// - **`unit:`** `ClientName <=> RigName, ...;`
/// Unit variant on both sides, same or different names.
///
/// - **`field:`** `Name { field } <=> Name, ...;`
/// Client struct with one named field mapped to a rig tuple variant.
///
/// - **`multi:`** `Name { a, b } <=> Name, ...;`
/// Both sides use named fields with the same field names.
///
/// - **`freq:`** `Name { field } <=> Name, ...;`
/// Client `u64` field converted to/from `Freq { hz }`.
///
/// - **`mode:`** `Name { field } <=> Name, ...;`
/// Client `String` field converted to/from `RigMode` via
/// `parse_mode`/`mode_to_string`.
macro_rules! define_command_mapping {
(
client_only: $( $co:ident ),* ;
unit: $( $cu:ident <=> $ru:ident ),* ;
field: $( $cf:ident { $fld:ident } <=> $rf:ident ),* ;
multi: $( $cs:ident { $( $sfld:ident ),+ } <=> $rs:ident ),* ;
freq: $( $cfq:ident { $ffld:ident } <=> $rfq:ident ),* ;
mode: $( $cm:ident { $mfld:ident } <=> $rm:ident ),* ;
) => {
/// Convert a [`ClientCommand`] to a [`RigCommand`].
///
/// # Panics
///
/// Panics if called with a client-only command (e.g. `GetRigs`,
/// `GetSatPasses`) that has no `RigCommand` counterpart. Those
/// commands must be intercepted by the caller before reaching this
/// function.
pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
match cmd {
// Client-only variants -- no RigCommand equivalent.
$(
ClientCommand::$co => {
panic!(
"{} has no RigCommand mapping; \
it must be handled before reaching rig_task",
stringify!($co),
);
}
)*
// Unit <=> Unit
$( ClientCommand::$cu => RigCommand::$ru, )*
// Single-field struct <=> tuple
$( ClientCommand::$cf { $fld } => RigCommand::$rf($fld), )*
// Multi-field struct passthrough
$( ClientCommand::$cs { $( $sfld ),+ } => RigCommand::$rs { $( $sfld ),+ }, )*
// Freq conversion (u64 => Freq)
$( ClientCommand::$cfq { $ffld } => RigCommand::$rfq(Freq { hz: $ffld }), )*
// Mode conversion (String => RigMode)
$( ClientCommand::$cm { $mfld } => RigCommand::$rm(parse_mode(&$mfld)), )*
}
}
/// Convert a [`RigCommand`] back to a [`ClientCommand`].
///
/// This is the inverse of [`client_command_to_rig`], converting
/// `RigMode` values back to mode strings.
pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
match cmd {
// Unit <=> Unit
$( RigCommand::$ru => ClientCommand::$cu, )*
// Single-field struct <=> tuple
$( RigCommand::$rf($fld) => ClientCommand::$cf { $fld }, )*
// Multi-field struct passthrough
$( RigCommand::$rs { $( $sfld ),+ } => ClientCommand::$cs { $( $sfld ),+ }, )*
// Freq conversion (Freq => u64)
$( RigCommand::$rfq(freq) => ClientCommand::$cfq { $ffld: freq.hz }, )*
// Mode conversion (RigMode => String)
$( RigCommand::$rm(mode) => ClientCommand::$cm {
$mfld: mode_to_string(&mode).into_owned(),
}, )*
}
}
};
}
define_command_mapping! {
// ── Client-only variants (no RigCommand counterpart) ─────────────
client_only: GetRigs, GetSatPasses, SubscribeMeter;
// ── Unit variants (no payload) ───────────────────────────────────
unit:
GetState <=> GetSnapshot,
PowerOn <=> PowerOn,
PowerOff <=> PowerOff,
ToggleVfo <=> ToggleVfo,
Lock <=> Lock,
Unlock <=> Unlock,
GetTxLimit <=> GetTxLimit,
GetSpectrum <=> GetSpectrum,
ResetAprsDecoder <=> ResetAprsDecoder,
ResetHfAprsDecoder <=> ResetHfAprsDecoder,
ResetCwDecoder <=> ResetCwDecoder,
ResetFt8Decoder <=> ResetFt8Decoder,
ResetFt4Decoder <=> ResetFt4Decoder,
ResetFt2Decoder <=> ResetFt2Decoder,
ResetWsprDecoder <=> ResetWsprDecoder,
ResetLrptDecoder <=> ResetLrptDecoder,
ResetWefaxDecoder <=> ResetWefaxDecoder;
// ── Single-field struct <=> tuple ────────────────────────────────
field:
SetPtt { ptt } <=> SetPtt,
SetTxLimit { limit } <=> SetTxLimit,
SetAprsDecodeEnabled { enabled } <=> SetAprsDecodeEnabled,
SetHfAprsDecodeEnabled { enabled } <=> SetHfAprsDecodeEnabled,
SetCwDecodeEnabled { enabled } <=> SetCwDecodeEnabled,
SetCwAuto { enabled } <=> SetCwAuto,
SetCwWpm { wpm } <=> SetCwWpm,
SetCwToneHz { tone_hz } <=> SetCwToneHz,
SetFt8DecodeEnabled { enabled } <=> SetFt8DecodeEnabled,
SetFt4DecodeEnabled { enabled } <=> SetFt4DecodeEnabled,
SetFt2DecodeEnabled { enabled } <=> SetFt2DecodeEnabled,
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
SetSdrAgc { enabled } <=> SetSdrAgc,
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
SetWfmStereo { enabled } <=> SetWfmStereo,
SetWfmDenoise { level } <=> SetWfmDenoise,
SetSamStereoWidth { width } <=> SetSamStereoWidth,
SetSamCarrierSync { enabled } <=> SetSamCarrierSync,
SetRecorderEnabled { enabled } <=> SetRecorderEnabled;
// ── Multi-field struct passthrough ───────────────────────────────
multi:
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
freq:
SetFreq { freq_hz } <=> SetFreq,
SetCenterFreq { freq_hz } <=> SetCenterFreq;
// ── Mode conversion (String <=> RigMode) ─────────────────────────
mode:
SetMode { mode } <=> SetMode;
}
#[cfg(test)]
mod tests {
use super::*;
use trx_core::rig::state::RigMode;
#[test]
fn test_client_command_to_rig_get_state() {
let cmd = ClientCommand::GetState;
if let RigCommand::GetSnapshot = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected GetSnapshot");
}
}
#[test]
fn test_client_command_to_rig_set_freq() {
let cmd = ClientCommand::SetFreq { freq_hz: 14100000 };
if let RigCommand::SetFreq(freq) = client_command_to_rig(cmd) {
assert_eq!(freq.hz, 14100000);
} else {
panic!("Expected SetFreq");
}
}
#[test]
fn test_client_command_to_rig_set_mode_lsb() {
let cmd = ClientCommand::SetMode {
mode: "LSB".to_string(),
};
if let RigCommand::SetMode(mode) = client_command_to_rig(cmd) {
assert_eq!(mode, RigMode::LSB);
} else {
panic!("Expected SetMode");
}
}
#[test]
fn test_client_command_to_rig_set_mode_unknown() {
let cmd = ClientCommand::SetMode {
mode: "UNKNOWN".to_string(),
};
if let RigCommand::SetMode(RigMode::Other(s)) = client_command_to_rig(cmd) {
assert_eq!(s, "UNKNOWN");
} else {
panic!("Expected SetMode with Other");
}
}
#[test]
fn test_client_command_to_rig_set_ptt() {
let cmd = ClientCommand::SetPtt { ptt: true };
if let RigCommand::SetPtt(ptt) = client_command_to_rig(cmd) {
assert!(ptt);
} else {
panic!("Expected SetPtt");
}
}
#[test]
fn test_client_command_to_rig_power_on() {
let cmd = ClientCommand::PowerOn;
if let RigCommand::PowerOn = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected PowerOn");
}
}
#[test]
fn test_client_command_to_rig_power_off() {
let cmd = ClientCommand::PowerOff;
if let RigCommand::PowerOff = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected PowerOff");
}
}
#[test]
fn test_client_command_to_rig_toggle_vfo() {
let cmd = ClientCommand::ToggleVfo;
if let RigCommand::ToggleVfo = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected ToggleVfo");
}
}
#[test]
fn test_client_command_to_rig_lock() {
let cmd = ClientCommand::Lock;
if let RigCommand::Lock = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected Lock");
}
}
#[test]
fn test_client_command_to_rig_unlock() {
let cmd = ClientCommand::Unlock;
if let RigCommand::Unlock = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected Unlock");
}
}
#[test]
fn test_client_command_to_rig_get_tx_limit() {
let cmd = ClientCommand::GetTxLimit;
if let RigCommand::GetTxLimit = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected GetTxLimit");
}
}
#[test]
fn test_client_command_to_rig_set_tx_limit() {
let cmd = ClientCommand::SetTxLimit { limit: 50 };
if let RigCommand::SetTxLimit(limit) = client_command_to_rig(cmd) {
assert_eq!(limit, 50);
} else {
panic!("Expected SetTxLimit");
}
}
#[test]
fn test_client_command_to_rig_set_aprs_decode_enabled() {
let cmd = ClientCommand::SetAprsDecodeEnabled { enabled: true };
if let RigCommand::SetAprsDecodeEnabled(enabled) = client_command_to_rig(cmd) {
assert!(enabled);
} else {
panic!("Expected SetAprsDecodeEnabled");
}
}
#[test]
fn test_client_command_to_rig_set_cw_decode_enabled() {
let cmd = ClientCommand::SetCwDecodeEnabled { enabled: false };
if let RigCommand::SetCwDecodeEnabled(enabled) = client_command_to_rig(cmd) {
assert!(!enabled);
} else {
panic!("Expected SetCwDecodeEnabled");
}
}
#[test]
fn test_client_command_to_rig_set_cw_auto() {
let cmd = ClientCommand::SetCwAuto { enabled: true };
if let RigCommand::SetCwAuto(enabled) = client_command_to_rig(cmd) {
assert!(enabled);
} else {
panic!("Expected SetCwAuto");
}
}
#[test]
fn test_client_command_to_rig_set_cw_wpm() {
let cmd = ClientCommand::SetCwWpm { wpm: 25 };
if let RigCommand::SetCwWpm(wpm) = client_command_to_rig(cmd) {
assert_eq!(wpm, 25);
} else {
panic!("Expected SetCwWpm");
}
}
#[test]
fn test_client_command_to_rig_set_cw_tone_hz() {
let cmd = ClientCommand::SetCwToneHz { tone_hz: 800 };
if let RigCommand::SetCwToneHz(tone_hz) = client_command_to_rig(cmd) {
assert_eq!(tone_hz, 800);
} else {
panic!("Expected SetCwToneHz");
}
}
#[test]
fn test_client_command_to_rig_set_ft8_decode_enabled() {
let cmd = ClientCommand::SetFt8DecodeEnabled { enabled: true };
if let RigCommand::SetFt8DecodeEnabled(enabled) = client_command_to_rig(cmd) {
assert!(enabled);
} else {
panic!("Expected SetFt8DecodeEnabled");
}
}
#[test]
fn test_client_command_to_rig_set_wspr_decode_enabled() {
let cmd = ClientCommand::SetWsprDecodeEnabled { enabled: true };
if let RigCommand::SetWsprDecodeEnabled(enabled) = client_command_to_rig(cmd) {
assert!(enabled);
} else {
panic!("Expected SetWsprDecodeEnabled");
}
}
#[test]
fn test_client_command_to_rig_reset_aprs_decoder() {
let cmd = ClientCommand::ResetAprsDecoder;
if let RigCommand::ResetAprsDecoder = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected ResetAprsDecoder");
}
}
#[test]
fn test_client_command_to_rig_reset_cw_decoder() {
let cmd = ClientCommand::ResetCwDecoder;
if let RigCommand::ResetCwDecoder = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected ResetCwDecoder");
}
}
#[test]
fn test_client_command_to_rig_reset_ft8_decoder() {
let cmd = ClientCommand::ResetFt8Decoder;
if let RigCommand::ResetFt8Decoder = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected ResetFt8Decoder");
}
}
#[test]
fn test_client_command_to_rig_reset_wspr_decoder() {
let cmd = ClientCommand::ResetWsprDecoder;
if let RigCommand::ResetWsprDecoder = client_command_to_rig(cmd) {
// Success
} else {
panic!("Expected ResetWsprDecoder");
}
}
#[test]
fn test_rig_command_to_client_get_snapshot() {
let cmd = RigCommand::GetSnapshot;
if let ClientCommand::GetState = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected GetState");
}
}
#[test]
fn test_rig_command_to_client_set_freq() {
let cmd = RigCommand::SetFreq(Freq { hz: 14100000 });
if let ClientCommand::SetFreq { freq_hz } = rig_command_to_client(cmd) {
assert_eq!(freq_hz, 14100000);
} else {
panic!("Expected SetFreq");
}
}
#[test]
fn test_rig_command_to_client_set_mode_lsb() {
let cmd = RigCommand::SetMode(RigMode::LSB);
if let ClientCommand::SetMode { mode } = rig_command_to_client(cmd) {
assert_eq!(mode, "LSB");
} else {
panic!("Expected SetMode");
}
}
#[test]
fn test_rig_command_to_client_set_mode_other() {
let cmd = RigCommand::SetMode(RigMode::Other("CUSTOM".to_string()));
if let ClientCommand::SetMode { mode } = rig_command_to_client(cmd) {
assert_eq!(mode, "CUSTOM");
} else {
panic!("Expected SetMode");
}
}
#[test]
fn test_rig_command_to_client_set_ptt() {
let cmd = RigCommand::SetPtt(true);
if let ClientCommand::SetPtt { ptt } = rig_command_to_client(cmd) {
assert!(ptt);
} else {
panic!("Expected SetPtt");
}
}
#[test]
fn test_rig_command_to_client_power_on() {
let cmd = RigCommand::PowerOn;
if let ClientCommand::PowerOn = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected PowerOn");
}
}
#[test]
fn test_rig_command_to_client_power_off() {
let cmd = RigCommand::PowerOff;
if let ClientCommand::PowerOff = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected PowerOff");
}
}
#[test]
fn test_rig_command_to_client_toggle_vfo() {
let cmd = RigCommand::ToggleVfo;
if let ClientCommand::ToggleVfo = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected ToggleVfo");
}
}
#[test]
fn test_rig_command_to_client_lock() {
let cmd = RigCommand::Lock;
if let ClientCommand::Lock = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected Lock");
}
}
#[test]
fn test_rig_command_to_client_unlock() {
let cmd = RigCommand::Unlock;
if let ClientCommand::Unlock = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected Unlock");
}
}
#[test]
fn test_rig_command_to_client_get_tx_limit() {
let cmd = RigCommand::GetTxLimit;
if let ClientCommand::GetTxLimit = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected GetTxLimit");
}
}
#[test]
fn test_rig_command_to_client_set_tx_limit() {
let cmd = RigCommand::SetTxLimit(50);
if let ClientCommand::SetTxLimit { limit } = rig_command_to_client(cmd) {
assert_eq!(limit, 50);
} else {
panic!("Expected SetTxLimit");
}
}
#[test]
fn test_rig_command_to_client_set_aprs_decode_enabled() {
let cmd = RigCommand::SetAprsDecodeEnabled(true);
if let ClientCommand::SetAprsDecodeEnabled { enabled } = rig_command_to_client(cmd) {
assert!(enabled);
} else {
panic!("Expected SetAprsDecodeEnabled");
}
}
#[test]
fn test_rig_command_to_client_set_cw_decode_enabled() {
let cmd = RigCommand::SetCwDecodeEnabled(false);
if let ClientCommand::SetCwDecodeEnabled { enabled } = rig_command_to_client(cmd) {
assert!(!enabled);
} else {
panic!("Expected SetCwDecodeEnabled");
}
}
#[test]
fn test_rig_command_to_client_set_cw_auto() {
let cmd = RigCommand::SetCwAuto(true);
if let ClientCommand::SetCwAuto { enabled } = rig_command_to_client(cmd) {
assert!(enabled);
} else {
panic!("Expected SetCwAuto");
}
}
#[test]
fn test_rig_command_to_client_set_cw_wpm() {
let cmd = RigCommand::SetCwWpm(25);
if let ClientCommand::SetCwWpm { wpm } = rig_command_to_client(cmd) {
assert_eq!(wpm, 25);
} else {
panic!("Expected SetCwWpm");
}
}
#[test]
fn test_rig_command_to_client_set_cw_tone_hz() {
let cmd = RigCommand::SetCwToneHz(800);
if let ClientCommand::SetCwToneHz { tone_hz } = rig_command_to_client(cmd) {
assert_eq!(tone_hz, 800);
} else {
panic!("Expected SetCwToneHz");
}
}
#[test]
fn test_rig_command_to_client_set_ft8_decode_enabled() {
let cmd = RigCommand::SetFt8DecodeEnabled(true);
if let ClientCommand::SetFt8DecodeEnabled { enabled } = rig_command_to_client(cmd) {
assert!(enabled);
} else {
panic!("Expected SetFt8DecodeEnabled");
}
}
#[test]
fn test_rig_command_to_client_set_wspr_decode_enabled() {
let cmd = RigCommand::SetWsprDecodeEnabled(true);
if let ClientCommand::SetWsprDecodeEnabled { enabled } = rig_command_to_client(cmd) {
assert!(enabled);
} else {
panic!("Expected SetWsprDecodeEnabled");
}
}
#[test]
fn test_rig_command_to_client_reset_aprs_decoder() {
let cmd = RigCommand::ResetAprsDecoder;
if let ClientCommand::ResetAprsDecoder = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected ResetAprsDecoder");
}
}
#[test]
fn test_rig_command_to_client_reset_cw_decoder() {
let cmd = RigCommand::ResetCwDecoder;
if let ClientCommand::ResetCwDecoder = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected ResetCwDecoder");
}
}
#[test]
fn test_rig_command_to_client_reset_ft8_decoder() {
let cmd = RigCommand::ResetFt8Decoder;
if let ClientCommand::ResetFt8Decoder = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected ResetFt8Decoder");
}
}
#[test]
fn test_rig_command_to_client_reset_wspr_decoder() {
let cmd = RigCommand::ResetWsprDecoder;
if let ClientCommand::ResetWsprDecoder = rig_command_to_client(cmd) {
// Success
} else {
panic!("Expected ResetWsprDecoder");
}
}
#[test]
fn test_round_trip_set_freq() {
let original = ClientCommand::SetFreq { freq_hz: 7050000 };
let rig_cmd = client_command_to_rig(original);
let client_cmd = rig_command_to_client(rig_cmd);
if let ClientCommand::SetFreq { freq_hz } = client_cmd {
assert_eq!(freq_hz, 7050000);
} else {
panic!("Round trip failed");
}
}
#[test]
fn test_round_trip_set_mode_standard() {
let original = ClientCommand::SetMode {
mode: "USB".to_string(),
};
let rig_cmd = client_command_to_rig(original);
let client_cmd = rig_command_to_client(rig_cmd);
if let ClientCommand::SetMode { mode } = client_cmd {
assert_eq!(mode, "USB");
} else {
panic!("Round trip failed");
}
}
#[test]
fn test_round_trip_set_ptt() {
let original = ClientCommand::SetPtt { ptt: false };
let rig_cmd = client_command_to_rig(original);
let client_cmd = rig_command_to_client(rig_cmd);
if let ClientCommand::SetPtt { ptt } = client_cmd {
assert!(!ptt);
} else {
panic!("Round trip failed");
}
}
#[test]
fn test_client_command_to_rig_set_recorder_enabled() {
let cmd = ClientCommand::SetRecorderEnabled { enabled: true };
if let RigCommand::SetRecorderEnabled(enabled) = client_command_to_rig(cmd) {
assert!(enabled);
} else {
panic!("Expected SetRecorderEnabled");
}
}
#[test]
fn test_rig_command_to_client_set_recorder_enabled() {
let cmd = RigCommand::SetRecorderEnabled(true);
if let ClientCommand::SetRecorderEnabled { enabled } = rig_command_to_client(cmd) {
assert!(enabled);
} else {
panic!("Expected SetRecorderEnabled");
}
}
#[test]
fn test_round_trip_set_recorder_enabled() {
let original = ClientCommand::SetRecorderEnabled { enabled: false };
let rig_cmd = client_command_to_rig(original);
let client_cmd = rig_command_to_client(rig_cmd);
if let ClientCommand::SetRecorderEnabled { enabled } = client_cmd {
assert!(!enabled);
} else {
panic!("Round trip failed");
}
}
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Transport DTOs for the JSON line protocol.
use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigSnapshot;
use trx_core::WfmDenoiseLevel;
/// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum ClientCommand {
GetState,
GetRigs,
GetSatPasses,
SetFreq {
freq_hz: u64,
},
SetCenterFreq {
freq_hz: u64,
},
SetMode {
mode: String,
},
SetPtt {
ptt: bool,
},
PowerOn,
PowerOff,
ToggleVfo,
Lock,
Unlock,
GetTxLimit,
SetTxLimit {
limit: u8,
},
SetAprsDecodeEnabled {
enabled: bool,
},
SetHfAprsDecodeEnabled {
enabled: bool,
},
SetCwDecodeEnabled {
enabled: bool,
},
SetCwAuto {
enabled: bool,
},
SetCwWpm {
wpm: u32,
},
SetCwToneHz {
tone_hz: u32,
},
SetFt8DecodeEnabled {
enabled: bool,
},
SetFt4DecodeEnabled {
enabled: bool,
},
SetFt2DecodeEnabled {
enabled: bool,
},
SetWsprDecodeEnabled {
enabled: bool,
},
SetLrptDecodeEnabled {
enabled: bool,
},
SetWefaxDecodeEnabled {
enabled: bool,
},
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
ResetFt8Decoder,
ResetFt4Decoder,
ResetFt2Decoder,
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
SetBandwidth {
bandwidth_hz: u32,
},
SetSdrGain {
gain_db: f64,
},
SetSdrLnaGain {
gain_db: f64,
},
SetSdrAgc {
enabled: bool,
},
SetSdrSquelch {
enabled: bool,
threshold_db: f64,
},
SetSdrNoiseBlanker {
enabled: bool,
threshold: f64,
},
SetWfmDeemphasis {
deemphasis_us: u32,
},
SetWfmStereo {
enabled: bool,
},
SetWfmDenoise {
level: WfmDenoiseLevel,
},
SetSamStereoWidth {
width: f32,
},
SetSamCarrierSync {
enabled: bool,
},
SetRecorderEnabled {
enabled: bool,
},
GetSpectrum,
/// Subscribe to a per-rig meter stream on this connection. After the
/// server receives this command, the connection becomes a one-way flow of
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
/// regular responses are sent. Intended for a dedicated TCP connection.
SubscribeMeter,
}
/// Fast meter sample pushed by the server on a dedicated meter stream.
///
/// Emitted at ~30 Hz for SDR backends and ~67 Hz for CAT backends.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MeterUpdate {
/// Rig identifier this sample belongs to.
pub rig_id: String,
/// Receive signal strength in dBm (rig/DSP-reported).
pub sig_dbm: f64,
/// Monotonic millisecond timestamp from the server's steady clock.
pub ts_ms: u64,
}
/// Envelope for client commands with optional authentication token and rig routing.
#[derive(Debug, Serialize, Deserialize)]
pub struct ClientEnvelope {
pub token: Option<String>,
/// Target rig ID. When absent, the first/default rig is used (backward compat).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// Protocol version advertised by the client. Absent for legacy clients.
/// Current version: 1.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<u32>,
#[serde(flatten)]
pub cmd: ClientCommand,
}
/// Current protocol version.
pub const PROTOCOL_VERSION: u32 = 1;
/// One entry in the GetRigs response: a rig's ID and its current snapshot.
#[derive(Debug, Serialize, Deserialize)]
pub struct RigEntry {
pub rig_id: String,
/// Display name for the rig (long name from config, or rig_id if not set).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub state: RigSnapshot,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio_port: Option<u16>,
}
/// Response sent to network clients over TCP.
#[derive(Debug, Serialize, Deserialize)]
pub struct ClientResponse {
pub success: bool,
/// The rig this response pertains to. Set by the listener from MR-06 onward.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// Protocol version of the server. Allows clients to detect capabilities.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<u32>,
pub state: Option<RigSnapshot>,
/// Populated only for GetRigs responses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigs: Option<Vec<RigEntry>>,
/// Populated only for GetSatPasses responses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sat_passes: Option<trx_core::geo::PassPredictionResult>,
pub error: Option<String>,
}