core: make rig snapshot serializable

This commit is contained in:
2026-01-18 09:18:54 +01:00
parent 025eb237b2
commit 6ef16f2cf4
8 changed files with 1251 additions and 27 deletions
@@ -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
+12 -2
View File
@@ -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<String>,
#[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<RigSnapshot>,
+613
View File
@@ -0,0 +1,613 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn set_mode<'a>(
&'a mut self,
mode: RigMode,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn set_ptt<'a>(
&'a mut self,
ptt: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn power_on<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn power_off<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn toggle_vfo<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn lock<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn unlock<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn get_tx_limit<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<u8>> + Send + 'a>>;
fn set_tx_limit<'a>(
&'a mut self,
limit: u8,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
fn refresh_state<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<dyn RigCommandHandler> {
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(_)));
}
}
+534
View File
@@ -0,0 +1,534 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// 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<u64>, // Unix timestamp, Option for serialization
}
impl RigStateError {
pub fn new(message: impl Into<String>, 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<String>) -> Self {
Self::new(message, true)
}
pub fn fatal(message: impl Into<String>) -> 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<u64> },
/// Connected but not yet initialized
Initializing { rig_info: Option<RigInfo> },
/// 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<RigMachineState>,
},
}
/// 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<RigVfo>,
pub rx: Option<RigRxStatus>,
pub tx_limit: Option<u8>,
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<RigVfo>,
pub tx: Option<RigTxStatus>,
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<Freq> {
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<RigStatus> {
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<Instant>,
}
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<Duration> {
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<RigMachineState> {
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<F>(&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<F>(&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));
}
}
+13 -12
View File
@@ -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<Box<dyn Future<Output = DynResult<(Freq, RigMode, Option<RigVfo>)>> + 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<Band>,
pub supported_modes: Vec<RigMode>,
@@ -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<RigVfoEntry>,
/// Index into `entries` for the active VFO, if known.
pub active: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RigVfoEntry {
pub name: String,
pub freq: Freq,
pub mode: Option<RigMode>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RigTxStatus {
pub power: Option<u8>,
pub limit: Option<u8>,
@@ -137,7 +138,7 @@ pub struct RigTxStatus {
pub alc: Option<u8>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RigRxStatus {
pub sig: Option<i32>,
}
+67 -3
View File
@@ -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<T> = Result<T, RigError>;
impl RigError {
/// Create a new transient error.
pub fn transient(message: impl Into<String>) -> Self {
Self {
message: message.into(),
kind: RigErrorKind::Transient,
}
}
/// Create a new permanent error.
pub fn permanent(message: impl Into<String>) -> 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<String>) -> Self {
Self::transient(message)
}
/// Create an invalid state error (permanent).
pub fn invalid_state(message: impl Into<String>) -> 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<String> 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 {}
+1 -1
View File
@@ -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,
@@ -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<RigInfoPlaceholder> 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![],