core: make rig snapshot serializable
This commit is contained in:
@@ -33,9 +33,9 @@ impl Ft817 {
|
|||||||
let builder = tokio_serial::new(path, baud);
|
let builder = tokio_serial::new(path, baud);
|
||||||
let port = builder.open_native_async()?;
|
let port = builder.open_native_async()?;
|
||||||
let info = RigInfo {
|
let info = RigInfo {
|
||||||
manufacturer: "Yaesu",
|
manufacturer: "Yaesu".to_string(),
|
||||||
model: "FT-817",
|
model: "FT-817".to_string(),
|
||||||
revision: "",
|
revision: "".to_string(),
|
||||||
capabilities: RigCapabilities {
|
capabilities: RigCapabilities {
|
||||||
supported_bands: vec![
|
supported_bands: vec![
|
||||||
// Transmit-capable amateur bands
|
// Transmit-capable amateur bands
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::rig::state::RigSnapshot;
|
use crate::rig::state::RigSnapshot;
|
||||||
|
|
||||||
/// Command received from network clients (JSON).
|
/// Command received from network clients (JSON).
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||||
pub enum ClientCommand {
|
pub enum ClientCommand {
|
||||||
GetState,
|
GetState,
|
||||||
@@ -17,12 +17,22 @@ pub enum ClientCommand {
|
|||||||
PowerOn,
|
PowerOn,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
ToggleVfo,
|
ToggleVfo,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
GetTxLimit,
|
GetTxLimit,
|
||||||
SetTxLimit { limit: u8 },
|
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.
|
/// Response sent to network clients over TCP.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct ClientResponse {
|
pub struct ClientResponse {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
pub state: Option<RigSnapshot>,
|
pub state: Option<RigSnapshot>,
|
||||||
|
|||||||
@@ -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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -5,7 +5,7 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::radio::freq::{Band, Freq};
|
use crate::radio::freq::{Band, Freq};
|
||||||
use crate::{DynResult, RigMode};
|
use crate::{DynResult, RigMode};
|
||||||
@@ -15,28 +15,29 @@ pub type RigStatusFuture<'a> =
|
|||||||
Pin<Box<dyn Future<Output = DynResult<(Freq, RigMode, Option<RigVfo>)>> + Send + 'a>>;
|
Pin<Box<dyn Future<Output = DynResult<(Freq, RigMode, Option<RigVfo>)>> + Send + 'a>>;
|
||||||
|
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod controller;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
/// How this backend communicates with the rig.
|
/// How this backend communicates with the rig.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum RigAccessMethod {
|
pub enum RigAccessMethod {
|
||||||
Serial { path: String, baud: u32 },
|
Serial { path: String, baud: u32 },
|
||||||
Tcp { addr: String },
|
Tcp { addr: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static info describing a rig backend.
|
/// Static info describing a rig backend.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigInfo {
|
pub struct RigInfo {
|
||||||
pub manufacturer: &'static str,
|
pub manufacturer: String,
|
||||||
pub model: &'static str,
|
pub model: String,
|
||||||
pub revision: &'static str,
|
pub revision: String,
|
||||||
pub capabilities: RigCapabilities,
|
pub capabilities: RigCapabilities,
|
||||||
pub access: RigAccessMethod,
|
pub access: RigAccessMethod,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigCapabilities {
|
pub struct RigCapabilities {
|
||||||
pub supported_bands: Vec<Band>,
|
pub supported_bands: Vec<Band>,
|
||||||
pub supported_modes: Vec<RigMode>,
|
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.
|
/// Snapshot of a rig's status that every backend can expose.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigStatus {
|
pub struct RigStatus {
|
||||||
pub freq: Freq,
|
pub freq: Freq,
|
||||||
pub mode: RigMode,
|
pub mode: RigMode,
|
||||||
@@ -115,21 +116,21 @@ pub trait RigStatusProvider {
|
|||||||
fn status(&self) -> RigStatus;
|
fn status(&self) -> RigStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigVfo {
|
pub struct RigVfo {
|
||||||
pub entries: Vec<RigVfoEntry>,
|
pub entries: Vec<RigVfoEntry>,
|
||||||
/// Index into `entries` for the active VFO, if known.
|
/// Index into `entries` for the active VFO, if known.
|
||||||
pub active: Option<usize>,
|
pub active: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigVfoEntry {
|
pub struct RigVfoEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub freq: Freq,
|
pub freq: Freq,
|
||||||
pub mode: Option<RigMode>,
|
pub mode: Option<RigMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigTxStatus {
|
pub struct RigTxStatus {
|
||||||
pub power: Option<u8>,
|
pub power: Option<u8>,
|
||||||
pub limit: Option<u8>,
|
pub limit: Option<u8>,
|
||||||
@@ -137,7 +138,7 @@ pub struct RigTxStatus {
|
|||||||
pub alc: Option<u8>,
|
pub alc: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigRxStatus {
|
pub struct RigRxStatus {
|
||||||
pub sig: Option<i32>,
|
pub sig: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,82 @@ use serde::Serialize;
|
|||||||
|
|
||||||
/// Error type returned by rig requests.
|
/// Error type returned by rig requests.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[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>;
|
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 {
|
impl From<String> for RigError {
|
||||||
fn from(value: String) -> Self {
|
fn from(value: String) -> Self {
|
||||||
RigError(value)
|
// Default to transient for backwards compatibility
|
||||||
|
RigError::transient(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&str> for RigError {
|
impl From<&str> for RigError {
|
||||||
fn from(value: &str) -> Self {
|
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 {}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ impl RigState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only projection of state shared with clients.
|
/// Read-only projection of state shared with clients.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RigSnapshot {
|
pub struct RigSnapshot {
|
||||||
pub info: RigInfo,
|
pub info: RigInfo,
|
||||||
pub status: RigStatus,
|
pub status: RigStatus,
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ use trx_core::{ClientResponse, RigCommand, RigMode, RigRequest, RigSnapshot, Rig
|
|||||||
|
|
||||||
use crate::server::status;
|
use crate::server::status;
|
||||||
|
|
||||||
const FAVICON_BYTES: &[u8] =
|
const FAVICON_BYTES: &[u8] = include_bytes!(concat!(
|
||||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-favicon.png"));
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/assets/trx-favicon.png"
|
||||||
|
));
|
||||||
const LOGO_BYTES: &[u8] =
|
const LOGO_BYTES: &[u8] =
|
||||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-logo.png"));
|
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 {
|
Ok(Err(err)) => Ok(HttpResponse::BadRequest().json(ClientResponse {
|
||||||
success: false,
|
success: false,
|
||||||
state: None,
|
state: None,
|
||||||
error: Some(err.0),
|
error: Some(err.message),
|
||||||
})),
|
})),
|
||||||
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
|
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
|
||||||
"rig response channel error: {e:?}"
|
"rig response channel error: {e:?}"
|
||||||
@@ -262,9 +264,9 @@ impl Default for RigInfoPlaceholder {
|
|||||||
impl From<RigInfoPlaceholder> for RigInfo {
|
impl From<RigInfoPlaceholder> for RigInfo {
|
||||||
fn from(_: RigInfoPlaceholder) -> Self {
|
fn from(_: RigInfoPlaceholder) -> Self {
|
||||||
RigInfo {
|
RigInfo {
|
||||||
manufacturer: "Unknown",
|
manufacturer: "Unknown".to_string(),
|
||||||
model: "Rig",
|
model: "Rig".to_string(),
|
||||||
revision: "",
|
revision: "".to_string(),
|
||||||
capabilities: RigCapabilities {
|
capabilities: RigCapabilities {
|
||||||
supported_bands: vec![],
|
supported_bands: vec![],
|
||||||
supported_modes: vec![],
|
supported_modes: vec![],
|
||||||
|
|||||||
Reference in New Issue
Block a user