[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:
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user