Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -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" }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ~6–7 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user