[feat](trx-protocol): add multi-rig protocol support

Add GetRigs command, rig_id routing fields, and RigEntry type:
- ClientCommand::GetRigs (intercepted in listener before rig_task)
- rig_id: Option<String> on ClientEnvelope (absent = first rig)
- rig_id: Option<String> and rigs: Option<Vec<RigEntry>> on ClientResponse
- RigEntry { rig_id, state } for GetRigs aggregated response
- Sentinel unreachable!() arm for GetRigs in client_command_to_rig()
- MR-09 codec tests: rig_id parsing, GetRigs round-trip, serde omit-when-None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-25 08:04:08 +01:00
parent 319e935d97
commit bb3464e32f
4 changed files with 106 additions and 3 deletions
+84 -1
View File
@@ -56,7 +56,11 @@ pub fn parse_envelope(input: &str) -> Result<ClientEnvelope, serde_json::Error>
Ok(envelope) => Ok(envelope), Ok(envelope) => Ok(envelope),
Err(_) => { Err(_) => {
let cmd = serde_json::from_str::<ClientCommand>(input)?; let cmd = serde_json::from_str::<ClientCommand>(input)?;
Ok(ClientEnvelope { token: None, cmd }) Ok(ClientEnvelope {
token: None,
rig_id: None,
cmd,
})
} }
} }
} }
@@ -196,4 +200,83 @@ mod tests {
let envelope = parse_envelope(json).unwrap(); let envelope = parse_envelope(json).unwrap();
assert_eq!(envelope.token, Some("Bearer abc123xyz".to_string())); 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()),
state: None,
rigs: 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,
state: None,
rigs: 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()),
state: None,
rigs: None,
error: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(!json.contains("\"rigs\""), "rigs=None should be omitted from JSON");
}
} }
+1 -1
View File
@@ -16,4 +16,4 @@ pub mod types;
pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator}; pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode}; pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use mapping::{client_command_to_rig, rig_command_to_client}; pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse}; pub use types::{ClientCommand, ClientEnvelope, ClientResponse, RigEntry};
+3
View File
@@ -16,6 +16,9 @@ use crate::types::ClientCommand;
/// mode strings into RigMode values. /// mode strings into RigMode values.
pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand { pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
match cmd { match cmd {
ClientCommand::GetRigs => {
unreachable!("GetRigs is handled in the listener before reaching rig_task")
}
ClientCommand::GetState => RigCommand::GetSnapshot, ClientCommand::GetState => RigCommand::GetSnapshot,
ClientCommand::SetFreq { freq_hz } => RigCommand::SetFreq(Freq { hz: freq_hz }), ClientCommand::SetFreq { freq_hz } => RigCommand::SetFreq(Freq { hz: freq_hz }),
ClientCommand::SetMode { mode } => RigCommand::SetMode(parse_mode(&mode)), ClientCommand::SetMode { mode } => RigCommand::SetMode(parse_mode(&mode)),
+18 -1
View File
@@ -13,6 +13,7 @@ use trx_core::rig::state::RigSnapshot;
#[serde(tag = "cmd", rename_all = "snake_case")] #[serde(tag = "cmd", rename_all = "snake_case")]
pub enum ClientCommand { pub enum ClientCommand {
GetState, GetState,
GetRigs,
SetFreq { freq_hz: u64 }, SetFreq { freq_hz: u64 },
SetMode { mode: String }, SetMode { mode: String },
SetPtt { ptt: bool }, SetPtt { ptt: bool },
@@ -36,18 +37,34 @@ pub enum ClientCommand {
ResetWsprDecoder, ResetWsprDecoder,
} }
/// Envelope for client commands with optional authentication token. /// Envelope for client commands with optional authentication token and rig routing.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ClientEnvelope { pub struct ClientEnvelope {
pub token: Option<String>, 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>,
#[serde(flatten)] #[serde(flatten)]
pub cmd: ClientCommand, pub cmd: ClientCommand,
} }
/// 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,
pub state: RigSnapshot,
}
/// Response sent to network clients over TCP. /// Response sent to network clients over TCP.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ClientResponse { pub struct ClientResponse {
pub success: bool, 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>,
pub state: Option<RigSnapshot>, pub state: Option<RigSnapshot>,
/// Populated only for GetRigs responses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigs: Option<Vec<RigEntry>>,
pub error: Option<String>, pub error: Option<String>,
} }