[refactor](trx-rs): resolve all improvement areas (P0-P3)

Addresses every item in docs/Improvement-Areas.md:

P0 - Plugin signing: new src/trx-app/src/plugins.rs with SHA-256 checksum
     manifest, filename allowlisting, API version compatibility checks,
     and cross-platform file permission validation.

P1 - Session store mutex poisoning: all .unwrap() calls on RwLock/Mutex in
     auth.rs replaced with .unwrap_or_else(|e| e.into_inner()) + warning logs.
   - TCP listener rate limiting: added ConnectionTracker with per-IP connection
     cap (10 concurrent connections per IP).
   - RigState refactoring: decoder fields grouped into DecoderConfig and
     DecoderResetSeqs sub-structs with #[serde(flatten)] for wire compat.
   - spawn_blocking timeout: satellite pass computation wrapped in 30s timeout.

P2 - Command handler macro: rig_command! macro generates 7 unit-struct command
     implementations, reducing ~200 lines of boilerplate.
   - Protocol versioning: added protocol_version field to ClientEnvelope and
     ClientResponse; improved unknown command error handling in parse_envelope.
   - Unsafe string: replaced from_utf8_unchecked with safe from_utf8().expect().
   - Dead code: removed 2 unnecessary annotations, documented remaining 4.

P3 - Tests: added 4 unit tests for history_store.rs (round-trip, expiry, etc).
   - FT-817 VFO: improved inference for ambiguous same-frequency case.
   - Configurator: implemented serial port detection via tokio_serial.
   - Plugin versioning: integrated into plugin manifest (api_version field).
   - Naming: documented as intentional semantic distinctions, not inconsistencies.

https://claude.ai/code/session_01Gj1vEkP6GKVcVaMqzFW885
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-29 11:06:23 +00:00
committed by Stan Grams
parent 8e3162d7e6
commit a69c5143e6
23 changed files with 1129 additions and 603 deletions
+4 -1
View File
@@ -15,5 +15,8 @@ pub type DynResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
pub use rig::command::RigCommand;
pub use rig::request::RigRequest;
pub use rig::response::{RigError, RigResult};
pub use rig::state::{RdsData, RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel};
pub use rig::state::{
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
WfmDenoiseLevel,
};
pub use rig::AudioSource;
+117 -179
View File
@@ -142,7 +142,73 @@ pub enum CommandResult {
// Concrete Command Implementations
// ============================================================================
/// Command to set the rig frequency.
/// Macro to generate unit-struct command implementations with standard
/// precondition checks, reducing repetitive boilerplate.
///
/// # Syntax
///
/// ```ignore
/// rig_command! {
/// /// Doc comment
/// UnitCommand("Name") {
/// preconditions: [initialized, unlocked],
/// execute: |executor| { executor.method().await?; Ok(CommandResult::Variant) },
/// }
/// }
/// ```
macro_rules! rig_command {
// Unit struct variant (no fields).
(
$(#[$meta:meta])*
$name:ident ($cmd_name:expr) {
preconditions: [$($precond:ident),*],
execute: |$exec:ident| $body:expr,
}
) => {
$(#[$meta])*
#[derive(Debug, Clone)]
pub struct $name;
impl RigCommandHandler for $name {
fn name(&self) -> &'static str {
$cmd_name
}
fn can_execute(&self, _ctx: &dyn CommandContext) -> ValidationResult {
$(rig_command!(@check _ctx, $precond);)*
ValidationResult::Ok
}
fn execute<'a>(
&'a self,
$exec: &'a mut dyn CommandExecutor,
) -> Pin<Box<dyn Future<Output = DynResult<CommandResult>> + Send + 'a>> {
Box::pin(async move { $body })
}
}
};
// Precondition expansion helpers.
(@check $ctx:ident, initialized) => {
if !$ctx.is_initialized() {
return ValidationResult::InvalidState("Rig not initialized".into());
}
};
(@check $ctx:ident, unlocked) => {
if $ctx.is_locked() {
return ValidationResult::Locked;
}
};
(@check $ctx:ident, not_transmitting) => {
if $ctx.is_transmitting() {
return ValidationResult::InvalidState(
"Cannot power off while transmitting".into(),
);
}
};
}
/// Command to set the rig frequency (custom validation for freq != 0).
#[derive(Debug, Clone)]
pub struct SetFreqCommand {
pub freq: Freq,
@@ -258,167 +324,6 @@ impl RigCommandHandler for SetPttCommand {
}
}
/// 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 {
@@ -455,28 +360,61 @@ impl RigCommandHandler for SetTxLimitCommand {
}
}
/// Command to get current state snapshot.
#[derive(Debug, Clone)]
pub struct GetSnapshotCommand;
// --- Macro-generated unit commands ---
impl RigCommandHandler for GetSnapshotCommand {
fn name(&self) -> &'static str {
"GetSnapshot"
rig_command! {
/// Command to power on the rig.
PowerOnCommand("PowerOn") {
preconditions: [],
execute: |executor| { executor.power_on().await?; Ok(CommandResult::PowerUpdated(true)) },
}
}
fn can_execute(&self, _ctx: &dyn CommandContext) -> ValidationResult {
// Getting snapshot can always be attempted
ValidationResult::Ok
rig_command! {
/// Command to power off the rig.
PowerOffCommand("PowerOff") {
preconditions: [not_transmitting],
execute: |executor| { executor.power_off().await?; Ok(CommandResult::PowerUpdated(false)) },
}
}
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)
})
rig_command! {
/// Command to toggle VFO.
ToggleVfoCommand("ToggleVfo") {
preconditions: [initialized, unlocked],
execute: |executor| { executor.toggle_vfo().await?; Ok(CommandResult::RefreshRequired) },
}
}
rig_command! {
/// Command to lock the panel.
LockCommand("Lock") {
preconditions: [initialized],
execute: |executor| { executor.lock().await?; Ok(CommandResult::LockUpdated(true)) },
}
}
rig_command! {
/// Command to unlock the panel.
UnlockCommand("Unlock") {
preconditions: [],
execute: |executor| { executor.unlock().await?; Ok(CommandResult::LockUpdated(false)) },
}
}
rig_command! {
/// Command to get TX limit.
GetTxLimitCommand("GetTxLimit") {
preconditions: [initialized],
execute: |executor| { let limit = executor.get_tx_limit().await?; Ok(CommandResult::TxLimitUpdated(limit)) },
}
}
rig_command! {
/// Command to get current state snapshot.
GetSnapshotCommand("GetSnapshot") {
preconditions: [],
execute: |executor| { executor.refresh_state().await?; Ok(CommandResult::RefreshRequired) },
}
}
+64 -92
View File
@@ -8,6 +8,55 @@ use uuid::Uuid;
use crate::radio::freq::Freq;
use crate::rig::{RigControl, RigInfo, RigRxStatus, RigStatus, RigStatusProvider, RigTxStatus};
/// Decoder enable/disable flags grouped for cleaner state management.
///
/// Flattened into `RigState` and `RigSnapshot` so the JSON wire format is
/// unchanged (backward compatible with existing clients).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct DecoderConfig {
#[serde(default)]
pub aprs_decode_enabled: bool,
#[serde(default)]
pub hf_aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool,
#[serde(default)]
pub ft8_decode_enabled: bool,
#[serde(default)]
pub ft4_decode_enabled: bool,
#[serde(default)]
pub ft2_decode_enabled: bool,
#[serde(default)]
pub wspr_decode_enabled: bool,
#[serde(default)]
pub lrpt_decode_enabled: bool,
}
/// Decoder reset sequence counters for invalidating decoder windows.
///
/// Each counter is incremented when the corresponding decoder is reset
/// (e.g. frequency change, explicit reset command). Decoder tasks compare
/// against a cached value to detect resets without being fully disabled.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct DecoderResetSeqs {
#[serde(default, skip_serializing)]
pub aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub hf_aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub cw_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub ft8_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub ft4_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub ft2_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub wspr_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub lrpt_decode_reset_seq: u64,
}
/// Simple transceiver state representation held by the rig task.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct RigState {
@@ -31,22 +80,9 @@ pub struct RigState {
pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
#[serde(default)]
pub aprs_decode_enabled: bool,
#[serde(default)]
pub hf_aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool,
#[serde(default)]
pub ft8_decode_enabled: bool,
#[serde(default)]
pub ft4_decode_enabled: bool,
#[serde(default)]
pub ft2_decode_enabled: bool,
#[serde(default)]
pub wspr_decode_enabled: bool,
#[serde(default)]
pub lrpt_decode_enabled: bool,
/// Decoder enable/disable flags.
#[serde(flatten)]
pub decoders: DecoderConfig,
#[serde(default)]
pub cw_auto: bool,
#[serde(default)]
@@ -65,22 +101,9 @@ pub struct RigState {
/// Skipped in serde (not part of persistent state); flows into RigSnapshot on demand.
#[serde(skip)]
pub vchan_rds: Option<Vec<VchanRdsEntry>>,
#[serde(default, skip_serializing)]
pub aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub hf_aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub cw_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub ft8_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub ft4_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub ft2_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub wspr_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub lrpt_decode_reset_seq: u64,
/// Decoder reset sequence counters.
#[serde(flatten)]
pub reset_seqs: DecoderResetSeqs,
}
/// Mode supported by the rig.
@@ -156,30 +179,14 @@ impl RigState {
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
decoders: DecoderConfig::default(),
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
filter: None,
spectrum: None,
vchan_rds: None,
aprs_decode_reset_seq: 0,
hf_aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0,
ft4_decode_reset_seq: 0,
ft2_decode_reset_seq: 0,
wspr_decode_reset_seq: 0,
lrpt_decode_reset_seq: 0,
reset_seqs: DecoderResetSeqs::default(),
}
}
@@ -229,29 +236,14 @@ impl RigState {
server_longitude: snapshot.server_longitude,
pskreporter_status: snapshot.pskreporter_status,
aprs_is_status: snapshot.aprs_is_status,
aprs_decode_enabled: snapshot.aprs_decode_enabled,
hf_aprs_decode_enabled: snapshot.hf_aprs_decode_enabled,
cw_decode_enabled: snapshot.cw_decode_enabled,
decoders: snapshot.decoders,
cw_auto: snapshot.cw_auto,
cw_wpm: snapshot.cw_wpm,
cw_tone_hz: snapshot.cw_tone_hz,
ft8_decode_enabled: snapshot.ft8_decode_enabled,
ft4_decode_enabled: snapshot.ft4_decode_enabled,
ft2_decode_enabled: snapshot.ft2_decode_enabled,
wspr_decode_enabled: snapshot.wspr_decode_enabled,
lrpt_decode_enabled: snapshot.lrpt_decode_enabled,
filter: snapshot.filter,
spectrum: None, // spectrum flows through /api/spectrum, not persistent state
vchan_rds: None, // vchan RDS flows through /api/spectrum, not persistent state
aprs_decode_reset_seq: 0,
hf_aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0,
ft4_decode_reset_seq: 0,
ft2_decode_reset_seq: 0,
wspr_decode_reset_seq: 0,
lrpt_decode_reset_seq: 0,
reset_seqs: DecoderResetSeqs::default(),
}
}
@@ -279,17 +271,10 @@ impl RigState {
server_longitude: self.server_longitude,
pskreporter_status: self.pskreporter_status.clone(),
aprs_is_status: self.aprs_is_status.clone(),
aprs_decode_enabled: self.aprs_decode_enabled,
hf_aprs_decode_enabled: self.hf_aprs_decode_enabled,
cw_decode_enabled: self.cw_decode_enabled,
decoders: self.decoders.clone(),
cw_auto: self.cw_auto,
cw_wpm: self.cw_wpm,
cw_tone_hz: self.cw_tone_hz,
ft8_decode_enabled: self.ft8_decode_enabled,
ft4_decode_enabled: self.ft4_decode_enabled,
ft2_decode_enabled: self.ft2_decode_enabled,
wspr_decode_enabled: self.wspr_decode_enabled,
lrpt_decode_enabled: self.lrpt_decode_enabled,
filter: self.filter.clone(),
spectrum: self.spectrum.clone(),
vchan_rds: self.vchan_rds.clone(),
@@ -306,7 +291,7 @@ impl RigState {
let cw_mode = matches!(mode, RigMode::CW | RigMode::CWR);
self.status.mode = mode;
if cw_mode {
self.cw_decode_enabled = true;
self.decoders.cw_decode_enabled = true;
}
}
@@ -486,22 +471,9 @@ pub struct RigSnapshot {
pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
#[serde(default)]
pub aprs_decode_enabled: bool,
#[serde(default)]
pub hf_aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool,
#[serde(default)]
pub ft8_decode_enabled: bool,
#[serde(default)]
pub ft4_decode_enabled: bool,
#[serde(default)]
pub ft2_decode_enabled: bool,
#[serde(default)]
pub wspr_decode_enabled: bool,
#[serde(default)]
pub lrpt_decode_enabled: bool,
/// Decoder enable/disable flags.
#[serde(flatten)]
pub decoders: DecoderConfig,
#[serde(default)]
pub cw_auto: bool,
#[serde(default)]