From 6ef16f2cf43f3083e553e14f1b6d272e9deb7971 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sun, 18 Jan 2026 09:18:54 +0100 Subject: [PATCH] core: make rig snapshot serializable --- .../src/trx-backend-ft817/src/lib.rs | 6 +- src/trx-core/src/client.rs | 14 +- src/trx-core/src/rig/controller/handlers.rs | 613 ++++++++++++++++++ src/trx-core/src/rig/controller/machine.rs | 534 +++++++++++++++ src/trx-core/src/rig/mod.rs | 25 +- src/trx-core/src/rig/response.rs | 70 +- src/trx-core/src/rig/state.rs | 2 +- .../src/trx-frontend-http/src/api.rs | 14 +- 8 files changed, 1251 insertions(+), 27 deletions(-) create mode 100644 src/trx-core/src/rig/controller/handlers.rs create mode 100644 src/trx-core/src/rig/controller/machine.rs diff --git a/src/trx-backend/src/trx-backend-ft817/src/lib.rs b/src/trx-backend/src/trx-backend-ft817/src/lib.rs index 6a5a6bb..39e7d37 100644 --- a/src/trx-backend/src/trx-backend-ft817/src/lib.rs +++ b/src/trx-backend/src/trx-backend-ft817/src/lib.rs @@ -33,9 +33,9 @@ impl Ft817 { let builder = tokio_serial::new(path, baud); let port = builder.open_native_async()?; let info = RigInfo { - manufacturer: "Yaesu", - model: "FT-817", - revision: "", + manufacturer: "Yaesu".to_string(), + model: "FT-817".to_string(), + revision: "".to_string(), capabilities: RigCapabilities { supported_bands: vec![ // Transmit-capable amateur bands diff --git a/src/trx-core/src/client.rs b/src/trx-core/src/client.rs index 688d4d1..96041fb 100644 --- a/src/trx-core/src/client.rs +++ b/src/trx-core/src/client.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::rig::state::RigSnapshot; /// Command received from network clients (JSON). -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum ClientCommand { GetState, @@ -17,12 +17,22 @@ pub enum ClientCommand { PowerOn, PowerOff, ToggleVfo, + Lock, + Unlock, GetTxLimit, SetTxLimit { limit: u8 }, } +/// Envelope for client commands with optional authentication token. +#[derive(Debug, Serialize, Deserialize)] +pub struct ClientEnvelope { + pub token: Option, + #[serde(flatten)] + pub cmd: ClientCommand, +} + /// Response sent to network clients over TCP. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ClientResponse { pub success: bool, pub state: Option, diff --git a/src/trx-core/src/rig/controller/handlers.rs b/src/trx-core/src/rig/controller/handlers.rs new file mode 100644 index 0000000..4059ec0 --- /dev/null +++ b/src/trx-core/src/rig/controller/handlers.rs @@ -0,0 +1,613 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Command handlers for rig operations. +//! +//! This module provides a trait-based command system where each command +//! is encapsulated in its own struct with validation and execution logic. + +use std::fmt::Debug; +use std::future::Future; +use std::pin::Pin; + +use crate::radio::freq::Freq; +use crate::rig::state::RigMode; +use crate::DynResult; + +use super::machine::RigMachineState; + +/// Result of command validation. +#[derive(Debug, Clone)] +pub enum ValidationResult { + /// Command can be executed. + Ok, + /// Command cannot be executed due to current state. + InvalidState(String), + /// Command parameters are invalid. + InvalidParams(String), + /// Panel is locked. + Locked, +} + +impl ValidationResult { + pub fn is_ok(&self) -> bool { + matches!(self, Self::Ok) + } +} + +/// Context provided to commands for execution. +/// This allows commands to access rig state without owning it. +pub trait CommandContext: Send { + /// Get the current state machine state. + fn state(&self) -> &RigMachineState; + + /// Check if the panel is locked. + fn is_locked(&self) -> bool { + self.state().is_locked() + } + + /// Check if the rig is initialized. + fn is_initialized(&self) -> bool { + self.state().is_initialized() + } + + /// Check if the rig is transmitting. + fn is_transmitting(&self) -> bool { + self.state().is_transmitting() + } +} + +/// Trait for rig commands following the Command Pattern. +/// +/// Each command encapsulates: +/// - Validation logic (`can_execute`) +/// - Execution logic (`execute`) +/// - Optional description for logging +pub trait RigCommandHandler: Debug + Send + Sync { + /// Human-readable name of the command. + fn name(&self) -> &'static str; + + /// Validate if the command can be executed in the current context. + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult; + + /// Execute the command. Returns the result of the operation. + /// The actual rig interaction is done via the executor passed to the pipeline. + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>>; +} + +/// Executor interface for commands to interact with the rig. +/// This abstracts the actual rig communication from the command logic. +pub trait CommandExecutor: Send { + fn set_freq<'a>( + &'a mut self, + freq: Freq, + ) -> Pin> + Send + 'a>>; + + fn set_mode<'a>( + &'a mut self, + mode: RigMode, + ) -> Pin> + Send + 'a>>; + + fn set_ptt<'a>( + &'a mut self, + ptt: bool, + ) -> Pin> + Send + 'a>>; + + fn power_on<'a>(&'a mut self) -> Pin> + Send + 'a>>; + + fn power_off<'a>(&'a mut self) -> Pin> + Send + 'a>>; + + fn toggle_vfo<'a>(&'a mut self) -> Pin> + Send + 'a>>; + + fn lock<'a>(&'a mut self) -> Pin> + Send + 'a>>; + + fn unlock<'a>(&'a mut self) -> Pin> + Send + 'a>>; + + fn get_tx_limit<'a>(&'a mut self) -> Pin> + Send + 'a>>; + + fn set_tx_limit<'a>( + &'a mut self, + limit: u8, + ) -> Pin> + Send + 'a>>; + + fn refresh_state<'a>(&'a mut self) -> Pin> + Send + 'a>>; +} + +/// Result of command execution containing any state updates. +#[derive(Debug, Clone)] +pub enum CommandResult { + /// Command executed successfully with no state change needed. + Ok, + /// Command executed and frequency was updated. + FreqUpdated(Freq), + /// Command executed and mode was updated. + ModeUpdated(RigMode), + /// Command executed and PTT state was updated. + PttUpdated(bool), + /// Command executed and power state was updated. + PowerUpdated(bool), + /// Command executed and lock state was updated. + LockUpdated(bool), + /// Command executed and TX limit was updated. + TxLimitUpdated(u8), + /// Command requires state refresh from rig. + RefreshRequired, +} + +// ============================================================================ +// Concrete Command Implementations +// ============================================================================ + +/// Command to set the rig frequency. +#[derive(Debug, Clone)] +pub struct SetFreqCommand { + pub freq: Freq, +} + +impl SetFreqCommand { + pub fn new(freq: Freq) -> Self { + Self { freq } + } +} + +impl RigCommandHandler for SetFreqCommand { + fn name(&self) -> &'static str { + "SetFreq" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + if ctx.is_locked() { + return ValidationResult::Locked; + } + if self.freq.hz == 0 { + return ValidationResult::InvalidParams("Frequency cannot be 0 Hz".into()); + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.set_freq(self.freq).await?; + Ok(CommandResult::FreqUpdated(self.freq)) + }) + } +} + +/// Command to set the rig mode. +#[derive(Debug, Clone)] +pub struct SetModeCommand { + pub mode: RigMode, +} + +impl SetModeCommand { + pub fn new(mode: RigMode) -> Self { + Self { mode } + } +} + +impl RigCommandHandler for SetModeCommand { + fn name(&self) -> &'static str { + "SetMode" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + if ctx.is_locked() { + return ValidationResult::Locked; + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + let mode = self.mode.clone(); + Box::pin(async move { + executor.set_mode(mode.clone()).await?; + Ok(CommandResult::ModeUpdated(mode)) + }) + } +} + +/// Command to set PTT state. +#[derive(Debug, Clone)] +pub struct SetPttCommand { + pub ptt: bool, +} + +impl SetPttCommand { + pub fn new(ptt: bool) -> Self { + Self { ptt } + } +} + +impl RigCommandHandler for SetPttCommand { + fn name(&self) -> &'static str { + "SetPtt" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + let ptt = self.ptt; + Box::pin(async move { + executor.set_ptt(ptt).await?; + Ok(CommandResult::PttUpdated(ptt)) + }) + } +} + +/// Command to power on the rig. +#[derive(Debug, Clone)] +pub struct PowerOnCommand; + +impl RigCommandHandler for PowerOnCommand { + fn name(&self) -> &'static str { + "PowerOn" + } + + fn can_execute(&self, _ctx: &dyn CommandContext) -> ValidationResult { + // Power on can always be attempted + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.power_on().await?; + Ok(CommandResult::PowerUpdated(true)) + }) + } +} + +/// Command to power off the rig. +#[derive(Debug, Clone)] +pub struct PowerOffCommand; + +impl RigCommandHandler for PowerOffCommand { + fn name(&self) -> &'static str { + "PowerOff" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if ctx.is_transmitting() { + return ValidationResult::InvalidState("Cannot power off while transmitting".into()); + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.power_off().await?; + Ok(CommandResult::PowerUpdated(false)) + }) + } +} + +/// Command to toggle VFO. +#[derive(Debug, Clone)] +pub struct ToggleVfoCommand; + +impl RigCommandHandler for ToggleVfoCommand { + fn name(&self) -> &'static str { + "ToggleVfo" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + if ctx.is_locked() { + return ValidationResult::Locked; + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.toggle_vfo().await?; + Ok(CommandResult::RefreshRequired) + }) + } +} + +/// Command to lock the panel. +#[derive(Debug, Clone)] +pub struct LockCommand; + +impl RigCommandHandler for LockCommand { + fn name(&self) -> &'static str { + "Lock" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.lock().await?; + Ok(CommandResult::LockUpdated(true)) + }) + } +} + +/// Command to unlock the panel. +#[derive(Debug, Clone)] +pub struct UnlockCommand; + +impl RigCommandHandler for UnlockCommand { + fn name(&self) -> &'static str { + "Unlock" + } + + fn can_execute(&self, _ctx: &dyn CommandContext) -> ValidationResult { + // Unlock can always be attempted + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.unlock().await?; + Ok(CommandResult::LockUpdated(false)) + }) + } +} + +/// Command to get TX limit. +#[derive(Debug, Clone)] +pub struct GetTxLimitCommand; + +impl RigCommandHandler for GetTxLimitCommand { + fn name(&self) -> &'static str { + "GetTxLimit" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let limit = executor.get_tx_limit().await?; + Ok(CommandResult::TxLimitUpdated(limit)) + }) + } +} + +/// Command to set TX limit. +#[derive(Debug, Clone)] +pub struct SetTxLimitCommand { + pub limit: u8, +} + +impl SetTxLimitCommand { + pub fn new(limit: u8) -> Self { + Self { limit } + } +} + +impl RigCommandHandler for SetTxLimitCommand { + fn name(&self) -> &'static str { + "SetTxLimit" + } + + fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult { + if !ctx.is_initialized() { + return ValidationResult::InvalidState("Rig not initialized".into()); + } + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + let limit = self.limit; + Box::pin(async move { + executor.set_tx_limit(limit).await?; + Ok(CommandResult::TxLimitUpdated(limit)) + }) + } +} + +/// Command to get current state snapshot. +#[derive(Debug, Clone)] +pub struct GetSnapshotCommand; + +impl RigCommandHandler for GetSnapshotCommand { + fn name(&self) -> &'static str { + "GetSnapshot" + } + + fn can_execute(&self, _ctx: &dyn CommandContext) -> ValidationResult { + // Getting snapshot can always be attempted + ValidationResult::Ok + } + + fn execute<'a>( + &'a self, + executor: &'a mut dyn CommandExecutor, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + executor.refresh_state().await?; + Ok(CommandResult::RefreshRequired) + }) + } +} + +// ============================================================================ +// Command Factory +// ============================================================================ + +use crate::rig::command::RigCommand; + +/// Convert from the existing RigCommand enum to a command handler. +pub fn command_from_rig_command(cmd: RigCommand) -> Box { + match cmd { + RigCommand::GetSnapshot => Box::new(GetSnapshotCommand), + RigCommand::SetFreq(freq) => Box::new(SetFreqCommand::new(freq)), + RigCommand::SetMode(mode) => Box::new(SetModeCommand::new(mode)), + RigCommand::SetPtt(ptt) => Box::new(SetPttCommand::new(ptt)), + RigCommand::PowerOn => Box::new(PowerOnCommand), + RigCommand::PowerOff => Box::new(PowerOffCommand), + RigCommand::ToggleVfo => Box::new(ToggleVfoCommand), + RigCommand::GetTxLimit => Box::new(GetTxLimitCommand), + RigCommand::SetTxLimit(limit) => Box::new(SetTxLimitCommand::new(limit)), + RigCommand::Lock => Box::new(LockCommand), + RigCommand::Unlock => Box::new(UnlockCommand), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockContext { + state: RigMachineState, + } + + impl CommandContext for MockContext { + fn state(&self) -> &RigMachineState { + &self.state + } + } + + #[test] + fn test_set_freq_validation_locked() { + use crate::rig::controller::machine::ReadyStateData; + use crate::rig::{RigAccessMethod, RigCapabilities, RigInfo}; + + let ctx = MockContext { + state: RigMachineState::Ready(ReadyStateData { + rig_info: RigInfo { + manufacturer: "Test".to_string(), + model: "Mock".to_string(), + revision: "1.0".to_string(), + capabilities: RigCapabilities { + supported_bands: vec![], + supported_modes: vec![], + num_vfos: 2, + lock: false, + lockable: true, + attenuator: false, + preamp: false, + rit: false, + rpt: false, + split: false, + }, + access: RigAccessMethod::Serial { + path: "/dev/test".to_string(), + baud: 9600, + }, + }, + freq: Freq { hz: 14_200_000 }, + mode: RigMode::USB, + vfo: None, + rx: None, + tx_limit: None, + locked: true, // Panel is locked + }), + }; + + let cmd = SetFreqCommand::new(Freq { hz: 14_300_000 }); + let result = cmd.can_execute(&ctx); + assert!(matches!(result, ValidationResult::Locked)); + } + + #[test] + fn test_set_freq_validation_not_initialized() { + let ctx = MockContext { + state: RigMachineState::Disconnected, + }; + + let cmd = SetFreqCommand::new(Freq { hz: 14_300_000 }); + let result = cmd.can_execute(&ctx); + assert!(matches!(result, ValidationResult::InvalidState(_))); + } + + #[test] + fn test_power_off_while_transmitting() { + use crate::rig::controller::machine::TransmittingStateData; + use crate::rig::{RigAccessMethod, RigCapabilities, RigInfo}; + + let ctx = MockContext { + state: RigMachineState::Transmitting(TransmittingStateData { + rig_info: RigInfo { + manufacturer: "Test".to_string(), + model: "Mock".to_string(), + revision: "1.0".to_string(), + capabilities: RigCapabilities { + supported_bands: vec![], + supported_modes: vec![], + num_vfos: 2, + lock: false, + lockable: true, + attenuator: false, + preamp: false, + rit: false, + rpt: false, + split: false, + }, + access: RigAccessMethod::Serial { + path: "/dev/test".to_string(), + baud: 9600, + }, + }, + freq: Freq { hz: 14_200_000 }, + mode: RigMode::USB, + vfo: None, + tx: None, + locked: false, + }), + }; + + let cmd = PowerOffCommand; + let result = cmd.can_execute(&ctx); + assert!(matches!(result, ValidationResult::InvalidState(_))); + } +} diff --git a/src/trx-core/src/rig/controller/machine.rs b/src/trx-core/src/rig/controller/machine.rs new file mode 100644 index 0000000..80c0b82 --- /dev/null +++ b/src/trx-core/src/rig/controller/machine.rs @@ -0,0 +1,534 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Rig state machine for lifecycle management. +//! +//! This module provides an explicit state machine for managing rig states, +//! making state transitions clear and preventing invalid states. + +use std::fmt; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use crate::radio::freq::Freq; +use crate::rig::state::RigMode; +use crate::rig::{RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo}; + +/// Events that can trigger state transitions in the rig state machine. +#[derive(Debug, Clone)] +pub enum RigEvent { + /// Connection to rig established + Connected, + /// Rig initialization complete + Initialized, + /// Rig powered on + PoweredOn, + /// Rig powered off + PoweredOff, + /// PTT engaged (transmitting) + PttOn, + /// PTT released (receiving) + PttOff, + /// Error occurred + Error(RigStateError), + /// Recovery from error + Recovered, + /// Disconnect requested or detected + Disconnected, +} + +/// Error information stored in error state. +#[derive(Debug, Clone, Serialize)] +pub struct RigStateError { + pub message: String, + pub recoverable: bool, + pub occurred_at: Option, // Unix timestamp, Option for serialization +} + +impl RigStateError { + pub fn new(message: impl Into, recoverable: bool) -> Self { + Self { + message: message.into(), + recoverable, + occurred_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), + } + } + + pub fn transient(message: impl Into) -> Self { + Self::new(message, true) + } + + pub fn fatal(message: impl Into) -> Self { + Self::new(message, false) + } +} + +/// The current state of the rig state machine. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(tag = "state", content = "data")] +pub enum RigMachineState { + /// Initial state, not connected to rig + #[default] + Disconnected, + /// Connecting to rig backend + Connecting { started_at: Option }, + /// Connected but not yet initialized + Initializing { rig_info: Option }, + /// Rig is powered off but connected + PoweredOff { rig_info: RigInfo }, + /// Rig is ready and idle (receiving) + Ready(ReadyStateData), + /// Rig is transmitting + Transmitting(TransmittingStateData), + /// Error state + Error { + error: RigStateError, + previous_state: Box, + }, +} + +/// Data held when rig is in Ready state. +#[derive(Debug, Clone, Serialize)] +pub struct ReadyStateData { + pub rig_info: RigInfo, + pub freq: Freq, + pub mode: RigMode, + pub vfo: Option, + pub rx: Option, + pub tx_limit: Option, + pub locked: bool, +} + +/// Data held when rig is in Transmitting state. +#[derive(Debug, Clone, Serialize)] +pub struct TransmittingStateData { + pub rig_info: RigInfo, + pub freq: Freq, + pub mode: RigMode, + pub vfo: Option, + pub tx: Option, + pub locked: bool, +} + +impl fmt::Display for RigMachineState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disconnected => write!(f, "Disconnected"), + Self::Connecting { .. } => write!(f, "Connecting"), + Self::Initializing { .. } => write!(f, "Initializing"), + Self::PoweredOff { .. } => write!(f, "PoweredOff"), + Self::Ready(_) => write!(f, "Ready"), + Self::Transmitting(_) => write!(f, "Transmitting"), + Self::Error { error, .. } => write!(f, "Error({})", error.message), + } + } +} + +impl RigMachineState { + /// Check if the rig is in a state where commands can be executed. + pub fn can_execute_commands(&self) -> bool { + matches!(self, Self::Ready(_) | Self::Transmitting(_)) + } + + /// Check if the rig is initialized. + pub fn is_initialized(&self) -> bool { + matches!( + self, + Self::Ready(_) | Self::Transmitting(_) | Self::PoweredOff { .. } + ) + } + + /// Check if the rig is transmitting. + pub fn is_transmitting(&self) -> bool { + matches!(self, Self::Transmitting(_)) + } + + /// Check if the rig is in an error state. + pub fn is_error(&self) -> bool { + matches!(self, Self::Error { .. }) + } + + /// Check if the panel is locked. + pub fn is_locked(&self) -> bool { + match self { + Self::Ready(data) => data.locked, + Self::Transmitting(data) => data.locked, + _ => false, + } + } + + /// Get the current frequency if available. + pub fn freq(&self) -> Option { + match self { + Self::Ready(data) => Some(data.freq), + Self::Transmitting(data) => Some(data.freq), + _ => None, + } + } + + /// Get the current mode if available. + pub fn mode(&self) -> Option<&RigMode> { + match self { + Self::Ready(data) => Some(&data.mode), + Self::Transmitting(data) => Some(&data.mode), + _ => None, + } + } + + /// Get rig info if available. + pub fn rig_info(&self) -> Option<&RigInfo> { + match self { + Self::Initializing { rig_info } => rig_info.as_ref(), + Self::PoweredOff { rig_info } => Some(rig_info), + Self::Ready(data) => Some(&data.rig_info), + Self::Transmitting(data) => Some(&data.rig_info), + Self::Error { previous_state, .. } => previous_state.rig_info(), + _ => None, + } + } + + /// Convert to RigStatus for compatibility with existing code. + pub fn to_rig_status(&self) -> Option { + match self { + Self::Ready(data) => Some(RigStatus { + freq: data.freq, + mode: data.mode.clone(), + tx_en: false, + vfo: data.vfo.clone(), + tx: Some(RigTxStatus { + power: Some(0), + limit: data.tx_limit, + swr: Some(0.0), + alc: None, + }), + rx: data.rx.clone(), + lock: Some(data.locked), + }), + Self::Transmitting(data) => Some(RigStatus { + freq: data.freq, + mode: data.mode.clone(), + tx_en: true, + vfo: data.vfo.clone(), + tx: data.tx.clone(), + rx: Some(RigRxStatus { sig: Some(0) }), + lock: Some(data.locked), + }), + _ => None, + } + } +} + +/// The rig state machine that manages state transitions. +#[derive(Debug, Clone)] +pub struct RigStateMachine { + state: RigMachineState, + transition_count: u64, + last_transition: Option, +} + +impl Default for RigStateMachine { + fn default() -> Self { + Self::new() + } +} + +impl RigStateMachine { + /// Create a new state machine in the Disconnected state. + pub fn new() -> Self { + Self { + state: RigMachineState::Disconnected, + transition_count: 0, + last_transition: None, + } + } + + /// Get the current state. + pub fn state(&self) -> &RigMachineState { + &self.state + } + + /// Get the number of state transitions that have occurred. + pub fn transition_count(&self) -> u64 { + self.transition_count + } + + /// Get the time since the last transition. + pub fn time_in_state(&self) -> Option { + self.last_transition.map(|t| t.elapsed()) + } + + /// Process an event and potentially transition to a new state. + /// Returns true if a transition occurred. + pub fn process_event(&mut self, event: RigEvent) -> bool { + let new_state = self.next_state(event); + if let Some(state) = new_state { + self.state = state; + self.transition_count += 1; + self.last_transition = Some(Instant::now()); + true + } else { + false + } + } + + /// Determine the next state based on current state and event. + fn next_state(&self, event: RigEvent) -> Option { + match (&self.state, event) { + // From Disconnected + (RigMachineState::Disconnected, RigEvent::Connected) => { + Some(RigMachineState::Connecting { + started_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), + }) + } + + // From Connecting + (RigMachineState::Connecting { .. }, RigEvent::Initialized) => { + Some(RigMachineState::Initializing { rig_info: None }) + } + + // From Initializing + (RigMachineState::Initializing { rig_info }, RigEvent::PoweredOn) => { + rig_info.as_ref().map(|info| { + RigMachineState::Ready(ReadyStateData { + rig_info: info.clone(), + freq: Freq { hz: 0 }, + mode: RigMode::USB, + vfo: None, + rx: None, + tx_limit: None, + locked: false, + }) + }) + } + (RigMachineState::Initializing { .. }, RigEvent::PoweredOff) => { + // Stay in initializing, rig is off + None + } + + // From PoweredOff + (RigMachineState::PoweredOff { rig_info }, RigEvent::PoweredOn) => { + Some(RigMachineState::Ready(ReadyStateData { + rig_info: rig_info.clone(), + freq: Freq { hz: 0 }, + mode: RigMode::USB, + vfo: None, + rx: None, + tx_limit: None, + locked: false, + })) + } + + // From Ready + (RigMachineState::Ready(data), RigEvent::PttOn) => { + Some(RigMachineState::Transmitting(TransmittingStateData { + rig_info: data.rig_info.clone(), + freq: data.freq, + mode: data.mode.clone(), + vfo: data.vfo.clone(), + tx: Some(RigTxStatus { + power: None, + limit: data.tx_limit, + swr: None, + alc: None, + }), + locked: data.locked, + })) + } + (RigMachineState::Ready(data), RigEvent::PoweredOff) => { + Some(RigMachineState::PoweredOff { + rig_info: data.rig_info.clone(), + }) + } + + // From Transmitting + (RigMachineState::Transmitting(data), RigEvent::PttOff) => { + Some(RigMachineState::Ready(ReadyStateData { + rig_info: data.rig_info.clone(), + freq: data.freq, + mode: data.mode.clone(), + vfo: data.vfo.clone(), + rx: None, + tx_limit: data.tx.as_ref().and_then(|t| t.limit), + locked: data.locked, + })) + } + (RigMachineState::Transmitting(data), RigEvent::PoweredOff) => { + Some(RigMachineState::PoweredOff { + rig_info: data.rig_info.clone(), + }) + } + + // Error transitions (from any state) + (current, RigEvent::Error(error)) => Some(RigMachineState::Error { + error, + previous_state: Box::new(current.clone()), + }), + + // Recovery from error + ( + RigMachineState::Error { + error, + previous_state, + }, + RigEvent::Recovered, + ) => { + if error.recoverable { + Some(*previous_state.clone()) + } else { + Some(RigMachineState::Disconnected) + } + } + + // Disconnect from any state + (_, RigEvent::Disconnected) => Some(RigMachineState::Disconnected), + + // Invalid transition - stay in current state + _ => None, + } + } + + /// Force set the state (for initialization or recovery). + pub fn set_state(&mut self, state: RigMachineState) { + self.state = state; + self.transition_count += 1; + self.last_transition = Some(Instant::now()); + } + + /// Update Ready state data in place. + pub fn update_ready_data(&mut self, f: F) + where + F: FnOnce(&mut ReadyStateData), + { + if let RigMachineState::Ready(ref mut data) = self.state { + f(data); + } + } + + /// Update Transmitting state data in place. + pub fn update_transmitting_data(&mut self, f: F) + where + F: FnOnce(&mut TransmittingStateData), + { + if let RigMachineState::Transmitting(ref mut data) = self.state { + f(data); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_rig_info() -> RigInfo { + use crate::rig::{RigAccessMethod, RigCapabilities}; + + RigInfo { + manufacturer: "Test".to_string(), + model: "Mock".to_string(), + revision: "1.0".to_string(), + capabilities: RigCapabilities { + supported_bands: vec![], + supported_modes: vec![], + num_vfos: 2, + lock: false, + lockable: true, + attenuator: false, + preamp: false, + rit: false, + rpt: false, + split: false, + }, + access: RigAccessMethod::Serial { + path: "/dev/test".to_string(), + baud: 9600, + }, + } + } + + #[test] + fn test_initial_state() { + let sm = RigStateMachine::new(); + assert!(matches!(sm.state(), RigMachineState::Disconnected)); + } + + #[test] + fn test_connect_transition() { + let mut sm = RigStateMachine::new(); + assert!(sm.process_event(RigEvent::Connected)); + assert!(matches!(sm.state(), RigMachineState::Connecting { .. })); + } + + #[test] + fn test_full_lifecycle() { + let mut sm = RigStateMachine::new(); + + // Connect + sm.process_event(RigEvent::Connected); + assert!(matches!(sm.state(), RigMachineState::Connecting { .. })); + + // Initialize + sm.process_event(RigEvent::Initialized); + assert!(matches!(sm.state(), RigMachineState::Initializing { .. })); + + // Set rig info and power on + sm.set_state(RigMachineState::Initializing { + rig_info: Some(mock_rig_info()), + }); + sm.process_event(RigEvent::PoweredOn); + assert!(matches!(sm.state(), RigMachineState::Ready(_))); + + // Transmit + sm.process_event(RigEvent::PttOn); + assert!(matches!(sm.state(), RigMachineState::Transmitting(_))); + assert!(sm.state().is_transmitting()); + + // Back to ready + sm.process_event(RigEvent::PttOff); + assert!(matches!(sm.state(), RigMachineState::Ready(_))); + + // Power off + sm.process_event(RigEvent::PoweredOff); + assert!(matches!(sm.state(), RigMachineState::PoweredOff { .. })); + } + + #[test] + fn test_error_and_recovery() { + let mut sm = RigStateMachine::new(); + sm.process_event(RigEvent::Connected); + sm.process_event(RigEvent::Initialized); + sm.set_state(RigMachineState::Initializing { + rig_info: Some(mock_rig_info()), + }); + sm.process_event(RigEvent::PoweredOn); + + // Trigger error + sm.process_event(RigEvent::Error(RigStateError::transient("Test error"))); + assert!(sm.state().is_error()); + + // Recover + sm.process_event(RigEvent::Recovered); + assert!(matches!(sm.state(), RigMachineState::Ready(_))); + } + + #[test] + fn test_invalid_transition() { + let mut sm = RigStateMachine::new(); + + // Can't transmit from disconnected + let transitioned = sm.process_event(RigEvent::PttOn); + assert!(!transitioned); + assert!(matches!(sm.state(), RigMachineState::Disconnected)); + } +} diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs index b17cef1..d58e5c4 100644 --- a/src/trx-core/src/rig/mod.rs +++ b/src/trx-core/src/rig/mod.rs @@ -5,7 +5,7 @@ use std::future::Future; use std::pin::Pin; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::radio::freq::{Band, Freq}; use crate::{DynResult, RigMode}; @@ -15,28 +15,29 @@ pub type RigStatusFuture<'a> = Pin)>> + Send + 'a>>; pub mod command; +pub mod controller; pub mod request; pub mod response; pub mod state; /// How this backend communicates with the rig. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum RigAccessMethod { Serial { path: String, baud: u32 }, Tcp { addr: String }, } /// Static info describing a rig backend. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigInfo { - pub manufacturer: &'static str, - pub model: &'static str, - pub revision: &'static str, + pub manufacturer: String, + pub model: String, + pub revision: String, pub capabilities: RigCapabilities, pub access: RigAccessMethod, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigCapabilities { pub supported_bands: Vec, pub supported_modes: Vec, @@ -99,7 +100,7 @@ pub trait RigCat: Rig + Send { } /// Snapshot of a rig's status that every backend can expose. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigStatus { pub freq: Freq, pub mode: RigMode, @@ -115,21 +116,21 @@ pub trait RigStatusProvider { fn status(&self) -> RigStatus; } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigVfo { pub entries: Vec, /// Index into `entries` for the active VFO, if known. pub active: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigVfoEntry { pub name: String, pub freq: Freq, pub mode: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigTxStatus { pub power: Option, pub limit: Option, @@ -137,7 +138,7 @@ pub struct RigTxStatus { pub alc: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigRxStatus { pub sig: Option, } diff --git a/src/trx-core/src/rig/response.rs b/src/trx-core/src/rig/response.rs index 06f5349..ab2a3df 100644 --- a/src/trx-core/src/rig/response.rs +++ b/src/trx-core/src/rig/response.rs @@ -6,18 +6,82 @@ use serde::Serialize; /// Error type returned by rig requests. #[derive(Debug, Clone, Serialize)] -pub struct RigError(pub String); +pub struct RigError { + pub message: String, + pub kind: RigErrorKind, +} + +/// Classification of rig errors for retry decisions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum RigErrorKind { + /// Temporary failure that may succeed on retry (timeout, busy). + Transient, + /// Permanent failure that won't be fixed by retrying. + Permanent, +} pub type RigResult = Result; +impl RigError { + /// Create a new transient error. + pub fn transient(message: impl Into) -> Self { + Self { + message: message.into(), + kind: RigErrorKind::Transient, + } + } + + /// Create a new permanent error. + pub fn permanent(message: impl Into) -> Self { + Self { + message: message.into(), + kind: RigErrorKind::Permanent, + } + } + + /// Create a timeout error (transient). + pub fn timeout() -> Self { + Self::transient("operation timed out") + } + + /// Create a not supported error (permanent). + pub fn not_supported(operation: &str) -> Self { + Self::permanent(format!("operation not supported: {}", operation)) + } + + /// Create a communication error (transient). + pub fn communication(message: impl Into) -> Self { + Self::transient(message) + } + + /// Create an invalid state error (permanent). + pub fn invalid_state(message: impl Into) -> Self { + Self::permanent(message) + } + + /// Check if this error is transient and may succeed on retry. + pub fn is_transient(&self) -> bool { + self.kind == RigErrorKind::Transient + } +} + impl From for RigError { fn from(value: String) -> Self { - RigError(value) + // Default to transient for backwards compatibility + RigError::transient(value) } } impl From<&str> for RigError { fn from(value: &str) -> Self { - RigError(value.to_string()) + RigError::transient(value) } } + +impl std::fmt::Display for RigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for RigError {} diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index 78e8b47..4e57668 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -83,7 +83,7 @@ impl RigState { } /// Read-only projection of state shared with clients. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RigSnapshot { pub info: RigInfo, pub status: RigStatus, diff --git a/src/trx-frontend/src/trx-frontend-http/src/api.rs b/src/trx-frontend/src/trx-frontend-http/src/api.rs index da27e06..9764934 100644 --- a/src/trx-frontend/src/trx-frontend-http/src/api.rs +++ b/src/trx-frontend/src/trx-frontend-http/src/api.rs @@ -16,8 +16,10 @@ use trx_core::{ClientResponse, RigCommand, RigMode, RigRequest, RigSnapshot, Rig use crate::server::status; -const FAVICON_BYTES: &[u8] = - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-favicon.png")); +const FAVICON_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/trx-favicon.png" +)); const LOGO_BYTES: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-logo.png")); @@ -218,7 +220,7 @@ async fn send_command( Ok(Err(err)) => Ok(HttpResponse::BadRequest().json(ClientResponse { success: false, state: None, - error: Some(err.0), + error: Some(err.message), })), Err(e) => Err(actix_web::error::ErrorInternalServerError(format!( "rig response channel error: {e:?}" @@ -262,9 +264,9 @@ impl Default for RigInfoPlaceholder { impl From for RigInfo { fn from(_: RigInfoPlaceholder) -> Self { RigInfo { - manufacturer: "Unknown", - model: "Rig", - revision: "", + manufacturer: "Unknown".to_string(), + model: "Rig".to_string(), + revision: "".to_string(), capabilities: RigCapabilities { supported_bands: vec![], supported_modes: vec![],