Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-05-17 23:25:14 +02:00
commit ba48de2d30
237 changed files with 105505 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-core"
version.workspace = true
edition = "2021"
[dependencies]
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
flate2 = { workspace = true }
uuid = { workspace = true }
sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
+405
View File
@@ -0,0 +1,405 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Audio streaming protocol types and framing helpers.
//!
//! Wire format: `[1 byte type][4 bytes BE length N][N bytes payload]`
use uuid::Uuid;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub const AUDIO_MSG_STREAM_INFO: u8 = 0x00;
pub const AUDIO_MSG_RX_FRAME: u8 = 0x01;
pub const AUDIO_MSG_TX_FRAME: u8 = 0x02;
pub const AUDIO_MSG_APRS_DECODE: u8 = 0x03;
pub const AUDIO_MSG_CW_DECODE: u8 = 0x04;
pub const AUDIO_MSG_FT8_DECODE: u8 = 0x05;
pub const AUDIO_MSG_WSPR_DECODE: u8 = 0x06;
pub const AUDIO_MSG_AIS_DECODE: u8 = 0x07;
pub const AUDIO_MSG_VDES_DECODE: u8 = 0x08;
pub const AUDIO_MSG_HF_APRS_DECODE: u8 = 0x09;
/// Compressed history blob: payload is a gzip-compressed sequence of normal
/// framed messages (each: `[1 byte type][4 bytes BE length][payload]`).
pub const AUDIO_MSG_HISTORY_COMPRESSED: u8 = 0x0a;
// ---------------------------------------------------------------------------
// Virtual-channel audio multiplexing (server → client)
// ---------------------------------------------------------------------------
/// Per-virtual-channel Opus frame: `[16 B UUID][opus_len B Opus]`.
/// Sent by the server for each virtual channel the client has subscribed to.
pub const AUDIO_MSG_RX_FRAME_CH: u8 = 0x0b;
/// Server → client: virtual channel audio subscription acknowledged.
/// Payload: 16-byte UUID of the newly activated channel slot.
pub const AUDIO_MSG_VCHAN_ALLOCATED: u8 = 0x0c;
// ---------------------------------------------------------------------------
// Virtual-channel audio multiplexing (client → server)
// ---------------------------------------------------------------------------
/// Client → server: create-or-subscribe to a virtual channel's audio.
/// Payload: JSON `{"uuid":"<uuid>","freq_hz":<u64>,"mode":"<mode>"}`.
/// If a channel with the given UUID already exists the server just subscribes;
/// otherwise it creates a new DSP pipeline at the given frequency/mode first.
pub const AUDIO_MSG_VCHAN_SUB: u8 = 0x0d;
/// Client → server: unsubscribe from a virtual channel's audio.
/// Payload: 16-byte UUID of the virtual channel on the server.
pub const AUDIO_MSG_VCHAN_UNSUB: u8 = 0x0e;
/// Client → server: update the dial frequency of a virtual channel.
/// Payload: JSON `{"uuid":"<uuid>","freq_hz":<u64>}`.
pub const AUDIO_MSG_VCHAN_FREQ: u8 = 0x0f;
/// Client → server: update the demodulation mode of a virtual channel.
/// Payload: JSON `{"uuid":"<uuid>","mode":"<mode>"}`.
pub const AUDIO_MSG_VCHAN_MODE: u8 = 0x10;
/// Client → server: remove a virtual channel (stops encoding and destroys the DSP pipeline).
/// Payload: 16-byte UUID of the virtual channel on the server.
pub const AUDIO_MSG_VCHAN_REMOVE: u8 = 0x11;
/// Server → client: a virtual channel was destroyed server-side (e.g. went out of bandwidth).
/// Payload: 16-byte UUID of the destroyed channel.
pub const AUDIO_MSG_VCHAN_DESTROYED: u8 = 0x12;
/// Client → server: update the audio filter bandwidth of an existing virtual channel.
/// Payload: JSON `{"uuid": "<uuid>", "bandwidth_hz": <u32>}`.
pub const AUDIO_MSG_VCHAN_BW: u8 = 0x13;
/// Server → client: FT4 decoded message (JSON `DecodedMessage::Ft4`).
pub const AUDIO_MSG_FT4_DECODE: u8 = 0x14;
/// Server → client: FT2 decoded message (JSON `DecodedMessage::Ft2`).
pub const AUDIO_MSG_FT2_DECODE: u8 = 0x15;
/// Server → client: Meteor-M LRPT image complete (JSON `DecodedMessage::LrptImage`).
pub const AUDIO_MSG_LRPT_IMAGE: u8 = 0x17;
/// Server → client: LRPT decode progress update (JSON `DecodedMessage::LrptProgress`).
pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
/// Server → client: WEFAX completed image (JSON `DecodedMessage::Wefax`).
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
/// Maximum payload size for normal messages (1 MB).
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
/// Maximum payload size for the compressed history blob (16 MB).
/// A compressed 24-hour history on a busy channel can reach several MB.
const MAX_HISTORY_PAYLOAD_SIZE: u32 = 16_777_216;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AudioStreamInfo {
pub sample_rate: u32,
pub channels: u8,
pub frame_duration_ms: u16,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub bitrate_bps: u32,
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
/// Write a length-prefixed audio message.
pub async fn write_audio_msg_buffered<W: AsyncWrite + Unpin>(
writer: &mut W,
msg_type: u8,
payload: &[u8],
) -> std::io::Result<()> {
let len = payload.len() as u32;
writer.write_u8(msg_type).await?;
writer.write_u32(len).await?;
writer.write_all(payload).await?;
Ok(())
}
/// Write a length-prefixed audio message and flush the writer.
pub async fn write_audio_msg<W: AsyncWrite + Unpin>(
writer: &mut W,
msg_type: u8,
payload: &[u8],
) -> std::io::Result<()> {
write_audio_msg_buffered(writer, msg_type, payload).await?;
writer.flush().await?;
Ok(())
}
/// Read one length-prefixed audio message, returning `(type, payload)`.
pub async fn read_audio_msg<R: AsyncRead + Unpin>(
reader: &mut R,
) -> std::io::Result<(u8, Vec<u8>)> {
let msg_type = reader.read_u8().await?;
let len = reader.read_u32().await?;
let limit = if msg_type == AUDIO_MSG_HISTORY_COMPRESSED {
MAX_HISTORY_PAYLOAD_SIZE
} else {
MAX_PAYLOAD_SIZE
};
if len > limit {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"audio frame too large: {} bytes (type={:#04x})",
len, msg_type
),
));
}
let mut payload = vec![0u8; len as usize];
reader.read_exact(&mut payload).await?;
Ok((msg_type, payload))
}
// ---------------------------------------------------------------------------
// Virtual-channel frame helpers
// ---------------------------------------------------------------------------
/// Write a virtual-channel control frame (16-byte UUID payload only).
/// Used for `AUDIO_MSG_VCHAN_SUB`, `AUDIO_MSG_VCHAN_UNSUB`, and
/// `AUDIO_MSG_VCHAN_ALLOCATED`.
pub async fn write_vchan_uuid_msg<W: AsyncWrite + Unpin>(
writer: &mut W,
msg_type: u8,
uuid: Uuid,
) -> std::io::Result<()> {
write_audio_msg(writer, msg_type, uuid.as_bytes()).await
}
/// Write an `AUDIO_MSG_RX_FRAME_CH` frame: 16-byte UUID followed by Opus payload.
pub async fn write_vchan_audio_frame<W: AsyncWrite + Unpin>(
writer: &mut W,
uuid: Uuid,
opus: &[u8],
) -> std::io::Result<()> {
let mut payload = Vec::with_capacity(16 + opus.len());
payload.extend_from_slice(uuid.as_bytes());
payload.extend_from_slice(opus);
write_audio_msg(writer, AUDIO_MSG_RX_FRAME_CH, &payload).await
}
/// Parse a virtual-channel audio frame payload (`AUDIO_MSG_RX_FRAME_CH`).
/// Returns `(uuid, opus_bytes)` or an error if the payload is too short.
pub fn parse_vchan_audio_frame(payload: &[u8]) -> std::io::Result<(Uuid, &[u8])> {
if payload.len() < 16 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"vchan audio frame payload too short",
));
}
let uuid = Uuid::from_bytes(payload[..16].try_into().unwrap());
Ok((uuid, &payload[16..]))
}
/// Parse a 16-byte UUID control frame (SUB / UNSUB / ALLOCATED).
pub fn parse_vchan_uuid_msg(payload: &[u8]) -> std::io::Result<Uuid> {
if payload.len() < 16 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"vchan uuid frame payload too short",
));
}
Ok(Uuid::from_bytes(payload[..16].try_into().unwrap()))
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::BufReader;
#[tokio::test]
async fn write_then_read_round_trip_preserves_type_and_payload() {
let mut buf: Vec<u8> = Vec::new();
let payload = b"hello, world";
write_audio_msg(&mut buf, AUDIO_MSG_FT8_DECODE, payload)
.await
.unwrap();
// Wire bytes: 1 byte type + 4 bytes BE length + payload.
assert_eq!(buf.len(), 1 + 4 + payload.len());
assert_eq!(buf[0], AUDIO_MSG_FT8_DECODE);
assert_eq!(
u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]),
payload.len() as u32
);
assert_eq!(&buf[5..], payload);
let mut reader = BufReader::new(&buf[..]);
let (msg_type, got) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_FT8_DECODE);
assert_eq!(got, payload);
}
#[tokio::test]
async fn write_then_read_handles_empty_payload() {
let mut buf: Vec<u8> = Vec::new();
write_audio_msg(&mut buf, AUDIO_MSG_RX_FRAME, &[])
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, got) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_RX_FRAME);
assert!(got.is_empty());
}
#[tokio::test]
async fn read_audio_msg_decodes_consecutive_frames() {
let mut buf: Vec<u8> = Vec::new();
write_audio_msg(&mut buf, AUDIO_MSG_FT8_DECODE, b"a")
.await
.unwrap();
write_audio_msg(&mut buf, AUDIO_MSG_FT4_DECODE, b"bb")
.await
.unwrap();
write_audio_msg(&mut buf, AUDIO_MSG_AIS_DECODE, b"ccc")
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (t, p) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!((t, p.as_slice()), (AUDIO_MSG_FT8_DECODE, b"a".as_slice()));
let (t, p) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!((t, p.as_slice()), (AUDIO_MSG_FT4_DECODE, b"bb".as_slice()));
let (t, p) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!((t, p.as_slice()), (AUDIO_MSG_AIS_DECODE, b"ccc".as_slice()));
}
#[tokio::test]
async fn read_audio_msg_eof_before_header_returns_unexpected_eof() {
let buf: Vec<u8> = Vec::new();
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[tokio::test]
async fn read_audio_msg_eof_mid_payload_returns_unexpected_eof() {
// Header claims 16 bytes; only 4 follow.
let mut buf: Vec<u8> = vec![AUDIO_MSG_FT8_DECODE];
buf.extend_from_slice(&16u32.to_be_bytes());
buf.extend_from_slice(&[0xAA; 4]);
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[tokio::test]
async fn read_audio_msg_rejects_oversize_normal_frame() {
// Type ≠ HISTORY_COMPRESSED → cap is MAX_PAYLOAD_SIZE (1 MiB). Claim
// 2 MiB and leave the body absent — we should fail before reading.
let mut buf: Vec<u8> = vec![AUDIO_MSG_FT8_DECODE];
buf.extend_from_slice(&(2 * 1024 * 1024u32).to_be_bytes());
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn read_audio_msg_history_frame_allows_larger_payload() {
// 2 MiB exceeds MAX_PAYLOAD_SIZE but is below MAX_HISTORY_PAYLOAD_SIZE.
// Reading should succeed when the type is HISTORY_COMPRESSED.
let payload = vec![0xCDu8; 2 * 1024 * 1024];
let mut buf: Vec<u8> = Vec::with_capacity(5 + payload.len());
write_audio_msg(&mut buf, AUDIO_MSG_HISTORY_COMPRESSED, &payload)
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, got) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_HISTORY_COMPRESSED);
assert_eq!(got.len(), payload.len());
}
#[tokio::test]
async fn read_audio_msg_history_frame_rejects_above_history_cap() {
// Claim 32 MiB, body absent. Should still fail (cap enforced even for
// history-compressed type).
let mut buf: Vec<u8> = vec![AUDIO_MSG_HISTORY_COMPRESSED];
buf.extend_from_slice(&(32 * 1024 * 1024u32).to_be_bytes());
let mut reader = BufReader::new(&buf[..]);
let err = read_audio_msg(&mut reader).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn vchan_uuid_msg_round_trip() {
let uuid = Uuid::new_v4();
let mut buf: Vec<u8> = Vec::new();
write_vchan_uuid_msg(&mut buf, AUDIO_MSG_VCHAN_SUB, uuid)
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, payload) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_VCHAN_SUB);
let got = parse_vchan_uuid_msg(&payload).unwrap();
assert_eq!(got, uuid);
}
#[test]
fn parse_vchan_uuid_msg_rejects_short_payload() {
let err = parse_vchan_uuid_msg(&[0u8; 8]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
// Empty payload also rejected.
let err = parse_vchan_uuid_msg(&[]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn vchan_audio_frame_round_trip() {
let uuid = Uuid::new_v4();
let opus = b"\x80\x81\x82 fake opus payload";
let mut buf: Vec<u8> = Vec::new();
write_vchan_audio_frame(&mut buf, uuid, opus).await.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, payload) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_RX_FRAME_CH);
let (got_uuid, got_opus) = parse_vchan_audio_frame(&payload).unwrap();
assert_eq!(got_uuid, uuid);
assert_eq!(got_opus, opus);
}
#[test]
fn parse_vchan_audio_frame_rejects_short_payload() {
let err = parse_vchan_audio_frame(&[0u8; 8]).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn parse_vchan_audio_frame_handles_empty_opus() {
// Exactly 16 bytes: UUID with no opus payload.
let uuid = Uuid::new_v4();
let payload = uuid.as_bytes().to_vec();
let (got_uuid, got_opus) = parse_vchan_audio_frame(&payload).unwrap();
assert_eq!(got_uuid, uuid);
assert!(got_opus.is_empty());
}
#[tokio::test]
async fn audio_stream_info_serialises_round_trip() {
let info = AudioStreamInfo {
sample_rate: 48_000,
channels: 2,
frame_duration_ms: 20,
bitrate_bps: 64_000,
};
let json = serde_json::to_vec(&info).unwrap();
let mut buf: Vec<u8> = Vec::new();
write_audio_msg(&mut buf, AUDIO_MSG_STREAM_INFO, &json)
.await
.unwrap();
let mut reader = BufReader::new(&buf[..]);
let (msg_type, payload) = read_audio_msg(&mut reader).await.unwrap();
assert_eq!(msg_type, AUDIO_MSG_STREAM_INFO);
let parsed: AudioStreamInfo = serde_json::from_slice(&payload).unwrap();
assert_eq!(parsed.sample_rate, info.sample_rate);
assert_eq!(parsed.channels, info.channels);
assert_eq!(parsed.frame_duration_ms, info.frame_duration_ms);
assert_eq!(parsed.bitrate_bps, info.bitrate_bps);
}
#[tokio::test]
async fn write_audio_msg_buffered_does_not_flush() {
// Smoke test that the buffered variant produces equivalent bytes to
// the flushed variant — Vec<u8> doesn't actually "buffer" so this is
// mostly a behavioural check that no extra padding/headers slip in.
let mut a: Vec<u8> = Vec::new();
let mut b: Vec<u8> = Vec::new();
write_audio_msg_buffered(&mut a, AUDIO_MSG_FT8_DECODE, b"x")
.await
.unwrap();
write_audio_msg(&mut b, AUDIO_MSG_FT8_DECODE, b"x")
.await
.unwrap();
assert_eq!(a, b);
}
}
+321
View File
@@ -0,0 +1,321 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Shared types for server-side decoded messages (APRS, AIS, CW).
use serde::{Deserialize, Serialize};
/// A decoded message from the server-side decoders.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DecodedMessage {
#[serde(rename = "ais")]
Ais(AisMessage),
#[serde(rename = "vdes")]
Vdes(VdesMessage),
#[serde(rename = "aprs")]
Aprs(AprsPacket),
#[serde(rename = "hf_aprs")]
HfAprs(AprsPacket),
#[serde(rename = "cw")]
Cw(CwEvent),
#[serde(rename = "ft8")]
Ft8(Ft8Message),
#[serde(rename = "ft4")]
Ft4(Ft8Message),
#[serde(rename = "ft2")]
Ft2(Ft8Message),
#[serde(rename = "wspr")]
Wspr(WsprMessage),
#[serde(rename = "lrpt_image")]
LrptImage(LrptImage),
#[serde(rename = "lrpt_progress")]
LrptProgress(LrptProgress),
#[serde(rename = "wefax")]
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
}
impl DecodedMessage {
/// Attach a rig identifier to the inner message variant.
pub fn set_rig_id(&mut self, id: String) {
match self {
Self::Ais(m) => m.rig_id = Some(id),
Self::Vdes(m) => m.rig_id = Some(id),
Self::Aprs(m) | Self::HfAprs(m) => m.rig_id = Some(id),
Self::Cw(m) => m.rig_id = Some(id),
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id = Some(id),
Self::Wspr(m) => m.rig_id = Some(id),
Self::LrptImage(m) => m.rig_id = Some(id),
Self::LrptProgress(m) => m.rig_id = Some(id),
Self::Wefax(m) => m.rig_id = Some(id),
Self::WefaxProgress(m) => m.rig_id = Some(id),
}
}
/// Return the rig identifier from the inner message variant, if set.
pub fn rig_id(&self) -> Option<&str> {
match self {
Self::Ais(m) => m.rig_id.as_deref(),
Self::Vdes(m) => m.rig_id.as_deref(),
Self::Aprs(m) | Self::HfAprs(m) => m.rig_id.as_deref(),
Self::Cw(m) => m.rig_id.as_deref(),
Self::Ft8(m) | Self::Ft4(m) | Self::Ft2(m) => m.rig_id.as_deref(),
Self::Wspr(m) => m.rig_id.as_deref(),
Self::LrptImage(m) => m.rig_id.as_deref(),
Self::LrptProgress(m) => m.rig_id.as_deref(),
Self::Wefax(m) => m.rig_id.as_deref(),
Self::WefaxProgress(m) => m.rig_id.as_deref(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AisMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
pub channel: String,
pub message_type: u8,
pub repeat: u8,
pub mmsi: u32,
pub crc_ok: bool,
pub bit_len: usize,
pub raw_bytes: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lat: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lon: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sog_knots: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cog_deg: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heading_deg: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nav_status: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vessel_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callsign: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub destination: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VdesMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
pub channel: String,
pub message_type: u8,
pub repeat: u8,
pub mmsi: u32,
pub crc_ok: bool,
pub bit_len: usize,
pub raw_bytes: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lat: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lon: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sog_knots: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cog_deg: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heading_deg: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nav_status: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vessel_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callsign: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub destination: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub destination_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_count: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub asm_identifier: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ack_nack_mask: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_quality: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub link_id: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_score: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_errors: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase_rotation: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fec_state: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AprsPacket {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
pub src_call: String,
pub dest_call: String,
pub path: String,
pub info: String,
pub info_bytes: Vec<u8>,
pub packet_type: String,
pub crc_ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub lat: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lon: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_table: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CwEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// Decoded text fragment (one or more characters)
pub text: String,
/// Current detected WPM
pub wpm: u32,
/// Current detected tone frequency (Hz)
pub tone_hz: u32,
/// Whether a CW tone is currently detected
pub signal_on: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ft8Message {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// UTC timestamp (milliseconds since epoch)
pub ts_ms: i64,
/// Approximate SNR (dB)
pub snr_db: f32,
/// Time offset within slot (seconds)
pub dt_s: f32,
/// Audio frequency (Hz)
pub freq_hz: f32,
/// Decoded message text
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsprMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// UTC timestamp (milliseconds since epoch)
pub ts_ms: i64,
/// Approximate SNR (dB)
pub snr_db: f32,
/// Time offset within slot (seconds)
pub dt_s: f32,
/// Audio frequency (Hz)
pub freq_hz: f32,
/// Decoded message text
pub message: String,
}
/// Live LRPT decode progress update, sent periodically during active decoding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LrptProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// Number of MCU rows decoded so far in this pass.
pub mcu_count: u32,
}
/// A completed Meteor-M LRPT satellite image, saved to disk as a PNG.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LrptImage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// UTC timestamp (milliseconds since epoch) of pass start.
pub pass_start_ms: i64,
/// UTC timestamp (milliseconds since epoch) when the image was finalised.
pub pass_end_ms: i64,
/// Number of decoded MCU rows.
pub mcu_count: u32,
/// Absolute filesystem path to the saved image file.
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
/// Identified satellite (e.g. "Meteor-M N2-3", "Meteor-M N2-4").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub satellite: Option<String>,
/// APID channels decoded (e.g. "64,65,66" for RGB).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channels: Option<String>,
/// Geographic bounds `[south, west, north, east]` for map overlay.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geo_bounds: Option<[f64; 4]>,
/// Ground track points `[[lat, lon], ...]` from SGP4 propagation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ground_track: Option<Vec<[f64; 2]>>,
}
/// A complete WEFAX image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WefaxMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
/// Number of image lines decoded.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line (IOC × π, rounded).
pub pixels_per_line: u16,
/// Filesystem path to saved PNG (set on completion).
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Base64-encoded PNG data for transfer to remote clients.
/// Populated by the server when sending, stripped before storing in history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub png_data: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
/// Progress update emitted per-line during active WEFAX reception.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WefaxProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// Number of image lines decoded so far.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line.
pub pixels_per_line: u16,
/// Base64-encoded greyscale line data (one row of pixels).
#[serde(skip_serializing_if = "Option::is_none")]
pub line_data: Option<String>,
/// Decoder state label (e.g. "APT Start 576", "Phasing", "Receiving").
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
pub mod audio;
pub mod decode;
pub mod geo;
pub mod math;
pub mod radio;
pub mod rig;
pub mod vchan;
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::{
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
WfmDenoiseLevel,
};
pub use rig::AudioSource;
+48
View File
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use crate::DynResult;
/// Encode frequency in Hz into 4 BCD bytes (10 Hz resolution) used by Yaesu CAT.
pub fn encode_freq_bcd(freq_hz: u64) -> DynResult<[u8; 4]> {
if !freq_hz.is_multiple_of(10) {
return Err("frequency must be a multiple of 10 Hz for CAT encoding".into());
}
let mut n = freq_hz / 10; // FT-817 uses 10 Hz units.
if n > 99_999_999 {
return Err("frequency out of range for CAT BCD encoding".into());
}
let mut digits = [0u8; 8];
for i in (0..8).rev() {
digits[i] = (n % 10) as u8;
n /= 10;
}
let mut out = [0u8; 4];
for i in 0..4 {
out[i] = (digits[i * 2] << 4) | digits[i * 2 + 1];
}
Ok(out)
}
/// Decode 4 BCD bytes (10 Hz resolution) into frequency in Hz.
pub fn decode_freq_bcd(bytes: [u8; 4]) -> DynResult<u64> {
let mut value = 0u64;
for b in bytes {
let high = (b >> 4) & 0x0F;
let low = b & 0x0F;
if high >= 10 || low >= 10 {
return Err("invalid BCD digit in frequency".into());
}
value = value * 10 + u64::from(high);
value = value * 10 + u64::from(low);
}
Ok(value * 10) // Convert back to Hz from 10 Hz units.
}
+7
View File
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
pub mod bcd;
pub use bcd::{decode_freq_bcd, encode_freq_bcd};
+72
View File
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::{Deserialize, Serialize};
const SPEED_OF_LIGHT_M_PER_S: f64 = 299_792_458.0;
/// Supported band range in Hz.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Band {
pub low_hz: u64,
pub high_hz: u64,
pub tx_allowed: bool,
}
impl Band {
/// Midpoint frequency of the band in Hz.
#[must_use]
pub fn center_hz(&self) -> u64 {
u64::midpoint(self.low_hz, self.high_hz)
}
}
/// Frequency wrapper (Hz).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct Freq {
pub hz: u64,
}
impl Freq {
#[must_use]
pub fn new(hz: u64) -> Self {
Self { hz }
}
/// Return the band name for this frequency, if any, using the provided band list.
pub fn band_name(&self, bands: &[Band]) -> Option<String> {
band_for_freq(bands, self).map(band_name)
}
}
/// Find the band that contains the given frequency (inclusive), if any.
pub fn band_for_freq<'a>(bands: &'a [Band], freq: &Freq) -> Option<&'a Band> {
bands
.iter()
.find(|b| freq.hz >= b.low_hz && freq.hz <= b.high_hz)
}
/// Convert a frequency in Hz to a human-friendly wavelength string.
///
/// Values above one meter are rounded to the nearest meter; shorter wavelengths
/// are shown in centimeters.
pub fn wavelength_label(freq_hz: u64) -> String {
if freq_hz == 0 {
return "-".to_string();
}
let wavelength_m = SPEED_OF_LIGHT_M_PER_S / (freq_hz as f64);
if wavelength_m >= 1.0 {
format!("{:.0}m", wavelength_m.round())
} else {
format!("{:.0}cm", (wavelength_m * 100.0).round())
}
}
/// Derive a human-friendly band label from a band's wavelength.
///
/// The label is computed from the wavelength at the band's center frequency.
pub fn band_name(band: &Band) -> String {
wavelength_label(band.center_hz())
}
+7
View File
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
pub mod freq;
pub use freq::{band_for_freq, band_name, wavelength_label, Band, Freq};
+58
View File
@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use crate::radio::freq::Freq;
use crate::rig::state::WfmDenoiseLevel;
use crate::RigMode;
/// Internal command handled by the rig task.
#[derive(Debug, Clone)]
pub enum RigCommand {
GetSnapshot,
SetFreq(Freq),
SetCenterFreq(Freq),
SetMode(RigMode),
SetPtt(bool),
PowerOn,
PowerOff,
ToggleVfo,
GetTxLimit,
SetTxLimit(u8),
Lock,
Unlock,
SetAprsDecodeEnabled(bool),
SetHfAprsDecodeEnabled(bool),
SetCwDecodeEnabled(bool),
SetCwAuto(bool),
SetCwWpm(u32),
SetCwToneHz(u32),
SetFt8DecodeEnabled(bool),
SetFt4DecodeEnabled(bool),
SetFt2DecodeEnabled(bool),
SetWsprDecodeEnabled(bool),
SetLrptDecodeEnabled(bool),
SetWefaxDecodeEnabled(bool),
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
ResetFt8Decoder,
ResetFt4Decoder,
ResetFt2Decoder,
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
SetBandwidth(u32),
SetSdrGain(f64),
SetSdrLnaGain(f64),
SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
SetWfmDeemphasis(u32),
SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel),
SetSamStereoWidth(f32),
SetSamCarrierSync(bool),
SetRecorderEnabled(bool),
GetSpectrum,
}
+207
View File
@@ -0,0 +1,207 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Rig event notification system.
//!
//! This module provides typed event notifications for rig state changes,
//! allowing frontends and other components to react to specific events.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use crate::radio::freq::Freq;
use crate::rig::state::RigMode;
use crate::rig::{RigRxStatus, RigTxStatus};
use super::machine::RigMachineState;
/// Unique identifier for a registered listener.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ListenerId(u64);
impl ListenerId {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
}
}
/// Trait for components that want to receive rig events.
///
/// Implementors receive typed notifications when rig state changes.
/// All methods have default no-op implementations, so listeners can
/// selectively override only the events they care about.
pub trait RigListener: Send + Sync {
/// Called when the operating frequency changes.
fn on_frequency_change(&self, _old: Option<Freq>, _new: Freq) {}
/// Called when the operating mode changes.
fn on_mode_change(&self, _old: Option<&RigMode>, _new: &RigMode) {}
/// Called when PTT state changes.
fn on_ptt_change(&self, _transmitting: bool) {}
/// Called when the rig state machine transitions.
fn on_state_change(&self, _old: &RigMachineState, _new: &RigMachineState) {}
/// Called when meter readings are updated.
fn on_meter_update(&self, _rx: Option<&RigRxStatus>, _tx: Option<&RigTxStatus>) {}
/// Called when the panel lock state changes.
fn on_lock_change(&self, _locked: bool) {}
/// Called when the rig powers on or off.
fn on_power_change(&self, _powered: bool) {}
}
/// Manages registered listeners and dispatches events.
pub struct RigEventEmitter {
listeners: Vec<(ListenerId, Arc<dyn RigListener>)>,
}
impl Default for RigEventEmitter {
fn default() -> Self {
Self::new()
}
}
impl RigEventEmitter {
/// Create a new event emitter with no listeners.
pub fn new() -> Self {
Self {
listeners: Vec::new(),
}
}
/// Register a listener to receive events.
/// Returns an ID that can be used to unregister the listener.
pub fn register(&mut self, listener: Arc<dyn RigListener>) -> ListenerId {
let id = ListenerId::new();
self.listeners.push((id, listener));
id
}
/// Unregister a listener by its ID.
pub fn unregister(&mut self, id: ListenerId) {
self.listeners.retain(|(lid, _)| *lid != id);
}
/// Get the number of registered listeners.
pub fn listener_count(&self) -> usize {
self.listeners.len()
}
/// Notify all listeners of a frequency change.
pub fn notify_frequency_change(&self, old: Option<Freq>, new: Freq) {
for (_, listener) in &self.listeners {
listener.on_frequency_change(old, new);
}
}
/// Notify all listeners of a mode change.
pub fn notify_mode_change(&self, old: Option<&RigMode>, new: &RigMode) {
for (_, listener) in &self.listeners {
listener.on_mode_change(old, new);
}
}
/// Notify all listeners of a PTT state change.
pub fn notify_ptt_change(&self, transmitting: bool) {
for (_, listener) in &self.listeners {
listener.on_ptt_change(transmitting);
}
}
/// Notify all listeners of a state machine transition.
pub fn notify_state_change(&self, old: &RigMachineState, new: &RigMachineState) {
for (_, listener) in &self.listeners {
listener.on_state_change(old, new);
}
}
/// Notify all listeners of updated meter readings.
pub fn notify_meter_update(&self, rx: Option<&RigRxStatus>, tx: Option<&RigTxStatus>) {
for (_, listener) in &self.listeners {
listener.on_meter_update(rx, tx);
}
}
/// Notify all listeners of a lock state change.
pub fn notify_lock_change(&self, locked: bool) {
for (_, listener) in &self.listeners {
listener.on_lock_change(locked);
}
}
/// Notify all listeners of a power state change.
pub fn notify_power_change(&self, powered: bool) {
for (_, listener) in &self.listeners {
listener.on_power_change(powered);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
struct TestListener {
freq_changed: AtomicBool,
ptt_changed: AtomicBool,
}
impl TestListener {
fn new() -> Self {
Self {
freq_changed: AtomicBool::new(false),
ptt_changed: AtomicBool::new(false),
}
}
}
impl RigListener for TestListener {
fn on_frequency_change(&self, _old: Option<Freq>, _new: Freq) {
self.freq_changed.store(true, Ordering::Relaxed);
}
fn on_ptt_change(&self, _transmitting: bool) {
self.ptt_changed.store(true, Ordering::Relaxed);
}
}
#[test]
fn test_register_and_notify() {
let mut emitter = RigEventEmitter::new();
let listener = Arc::new(TestListener::new());
let id = emitter.register(listener.clone());
assert_eq!(emitter.listener_count(), 1);
emitter.notify_frequency_change(None, Freq { hz: 14_200_000 });
assert!(listener.freq_changed.load(Ordering::Relaxed));
assert!(!listener.ptt_changed.load(Ordering::Relaxed));
emitter.notify_ptt_change(true);
assert!(listener.ptt_changed.load(Ordering::Relaxed));
emitter.unregister(id);
assert_eq!(emitter.listener_count(), 0);
}
#[test]
fn test_multiple_listeners() {
let mut emitter = RigEventEmitter::new();
let listener1 = Arc::new(TestListener::new());
let listener2 = Arc::new(TestListener::new());
emitter.register(listener1.clone());
emitter.register(listener2.clone());
emitter.notify_frequency_change(Some(Freq { hz: 7_000_000 }), Freq { hz: 14_200_000 });
assert!(listener1.freq_changed.load(Ordering::Relaxed));
assert!(listener2.freq_changed.load(Ordering::Relaxed));
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Command executor implementation that bridges to RigCat.
use std::future::Future;
use std::pin::Pin;
use crate::radio::freq::Freq;
use crate::rig::state::RigMode;
use crate::rig::RigCat;
use crate::DynResult;
use super::handlers::CommandExecutor;
/// Executor that delegates to a RigCat implementation.
pub struct RigCatExecutor<'a> {
rig: &'a mut dyn RigCat,
}
impl<'a> RigCatExecutor<'a> {
pub fn new(rig: &'a mut dyn RigCat) -> Self {
Self { rig }
}
}
impl<'a> CommandExecutor for RigCatExecutor<'a> {
fn set_freq<'b>(
&'b mut self,
freq: Freq,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.set_freq(freq)
}
fn set_mode<'b>(
&'b mut self,
mode: RigMode,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.set_mode(mode)
}
fn set_ptt<'b>(
&'b mut self,
ptt: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.set_ptt(ptt)
}
fn power_on<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.power_on()
}
fn power_off<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.power_off()
}
fn toggle_vfo<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.toggle_vfo()
}
fn lock<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.lock()
}
fn unlock<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.unlock()
}
fn get_tx_limit<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<u8>> + Send + 'b>> {
self.rig.get_tx_limit()
}
fn set_tx_limit<'b>(
&'b mut self,
limit: u8,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
self.rig.set_tx_limit(limit)
}
fn refresh_state<'b>(&'b mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'b>> {
// This is a no-op for the executor - the controller handles state refresh
Box::pin(async { Ok(()) })
}
}
+600
View File
@@ -0,0 +1,600 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! 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
// ============================================================================
/// 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,
}
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 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))
})
}
}
// --- Macro-generated unit commands ---
rig_command! {
/// Command to power on the rig.
PowerOnCommand("PowerOn") {
preconditions: [],
execute: |executor| { executor.power_on().await?; Ok(CommandResult::PowerUpdated(true)) },
}
}
rig_command! {
/// Command to power off the rig.
PowerOffCommand("PowerOff") {
preconditions: [not_transmitting],
execute: |executor| { executor.power_off().await?; Ok(CommandResult::PowerUpdated(false)) },
}
}
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) },
}
}
// ============================================================================
// 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::SetCenterFreq(_) => Box::new(GetSnapshotCommand),
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),
// Decoder commands are handled before reaching this function;
// map to GetSnapshot as a safe fallback.
RigCommand::SetAprsDecodeEnabled(_)
| RigCommand::SetCwDecodeEnabled(_)
| RigCommand::SetCwAuto(_)
| RigCommand::SetCwWpm(_)
| RigCommand::SetCwToneHz(_)
| RigCommand::SetFt8DecodeEnabled(_)
| RigCommand::SetFt4DecodeEnabled(_)
| RigCommand::SetFt2DecodeEnabled(_)
| RigCommand::SetWsprDecodeEnabled(_)
| RigCommand::SetHfAprsDecodeEnabled(_)
| RigCommand::ResetHfAprsDecoder
| RigCommand::ResetAprsDecoder
| RigCommand::ResetCwDecoder
| RigCommand::ResetFt8Decoder
| RigCommand::ResetFt4Decoder
| RigCommand::ResetFt2Decoder
| RigCommand::ResetWsprDecoder
| RigCommand::SetLrptDecodeEnabled(_)
| RigCommand::ResetLrptDecoder
| RigCommand::SetWefaxDecodeEnabled(_)
| RigCommand::ResetWefaxDecoder
| RigCommand::SetBandwidth(_)
| RigCommand::SetSdrGain(_)
| RigCommand::SetSdrLnaGain(_)
| RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. }
| RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_)
| RigCommand::SetSamStereoWidth(_)
| RigCommand::SetSamCarrierSync(_)
| RigCommand::SetRecorderEnabled(_)
| RigCommand::GetSpectrum => Box::new(GetSnapshotCommand),
}
}
#[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 {
min_freq_step_hz: 1,
supported_bands: vec![],
supported_modes: vec![],
num_vfos: 2,
lock: false,
lockable: true,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: true,
tx_limit: true,
vfo_switch: true,
filter_controls: false,
signal_meter: true,
},
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 {
min_freq_step_hz: 1,
supported_bands: vec![],
supported_modes: vec![],
num_vfos: 2,
lock: false,
lockable: true,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: true,
tx_limit: true,
vfo_switch: true,
filter_controls: false,
signal_meter: true,
},
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(_)));
}
}
+634
View File
@@ -0,0 +1,634 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! 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 tracing::debug;
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.
///
/// Fields are crate-private to prevent external mutation that could bypass
/// state machine invariants. Use the constructor and accessor methods.
#[derive(Debug, Clone, Serialize)]
pub struct ReadyStateData {
pub(crate) rig_info: RigInfo,
pub(crate) freq: Freq,
pub(crate) mode: RigMode,
pub(crate) vfo: Option<RigVfo>,
pub(crate) rx: Option<RigRxStatus>,
pub(crate) tx_limit: Option<u8>,
pub(crate) locked: bool,
}
impl ReadyStateData {
pub fn new(
rig_info: RigInfo,
freq: Freq,
mode: RigMode,
vfo: Option<RigVfo>,
rx: Option<RigRxStatus>,
tx_limit: Option<u8>,
locked: bool,
) -> Self {
Self {
rig_info,
freq,
mode,
vfo,
rx,
tx_limit,
locked,
}
}
pub fn rig_info(&self) -> &RigInfo {
&self.rig_info
}
pub fn freq(&self) -> Freq {
self.freq
}
pub fn mode(&self) -> &RigMode {
&self.mode
}
pub fn vfo(&self) -> Option<&RigVfo> {
self.vfo.as_ref()
}
pub fn rx(&self) -> Option<&RigRxStatus> {
self.rx.as_ref()
}
pub fn tx_limit(&self) -> Option<u8> {
self.tx_limit
}
pub fn is_locked(&self) -> bool {
self.locked
}
}
/// Data held when rig is in Transmitting state.
///
/// Fields are crate-private to prevent external mutation that could bypass
/// state machine invariants. Use the constructor and accessor methods.
#[derive(Debug, Clone, Serialize)]
pub struct TransmittingStateData {
pub(crate) rig_info: RigInfo,
pub(crate) freq: Freq,
pub(crate) mode: RigMode,
pub(crate) vfo: Option<RigVfo>,
pub(crate) tx: Option<RigTxStatus>,
pub(crate) locked: bool,
}
impl TransmittingStateData {
pub fn new(
rig_info: RigInfo,
freq: Freq,
mode: RigMode,
vfo: Option<RigVfo>,
tx: Option<RigTxStatus>,
locked: bool,
) -> Self {
Self {
rig_info,
freq,
mode,
vfo,
tx,
locked,
}
}
pub fn rig_info(&self) -> &RigInfo {
&self.rig_info
}
pub fn freq(&self) -> Freq {
self.freq
}
pub fn mode(&self) -> &RigMode {
&self.mode
}
pub fn vfo(&self) -> Option<&RigVfo> {
self.vfo.as_ref()
}
pub fn tx(&self) -> Option<&RigTxStatus> {
self.tx.as_ref()
}
pub fn is_locked(&self) -> bool {
self.locked
}
}
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.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.clone());
if let Some(state) = new_state {
self.state = state;
self.transition_count += 1;
self.last_transition = Some(Instant::now());
true
} else {
debug!(
"Invalid state transition: {:?} + {:?} (ignored)",
self.state, event
);
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 {
min_freq_step_hz: 1,
supported_bands: vec![],
supported_modes: vec![],
num_vfos: 2,
lock: false,
lockable: true,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: true,
tx_limit: true,
vfo_switch: true,
filter_controls: false,
signal_meter: true,
},
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));
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Rig controller components.
//!
//! This module contains the core control logic for managing rig state,
//! handling commands, emitting events, and configuring operational policies.
pub mod events;
pub mod executor;
pub mod handlers;
pub mod machine;
pub mod policies;
pub use events::{ListenerId, RigEventEmitter, RigListener};
pub use executor::RigCatExecutor;
pub use handlers::{
command_from_rig_command, CommandContext, CommandExecutor, CommandResult, RigCommandHandler,
ValidationResult,
};
pub use machine::{
ReadyStateData, RigEvent, RigMachineState, RigStateError, RigStateMachine,
TransmittingStateData,
};
pub use policies::{AdaptivePolling, ExponentialBackoff, PollingPolicy, RetryPolicy};
+333
View File
@@ -0,0 +1,333 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Rig operational policies for retry and polling behavior.
//!
//! This module provides configurable policies that control how the rig
//! controller handles retries after failures and polling intervals.
use std::time::Duration;
use crate::rig::response::RigError;
/// Apply ±25% jitter to a duration to prevent thundering herd on reconnect.
fn apply_jitter(delay: Duration) -> Duration {
// Simple deterministic-ish jitter using the current instant's low bits.
// We avoid pulling in `rand` for this single use.
let nanos = std::time::Instant::now().elapsed().as_nanos().wrapping_add(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
);
// Map to range [0.75, 1.25]
let frac = (nanos % 1000) as f64 / 1000.0; // 0.0 .. 1.0
let factor = 0.75 + frac * 0.5; // 0.75 .. 1.25
Duration::from_secs_f64(delay.as_secs_f64() * factor)
}
/// Policy for retrying failed operations.
pub trait RetryPolicy: Send + Sync {
/// Determine if the operation should be retried.
fn should_retry(&self, attempt: u32, error: &RigError) -> bool;
/// Get the delay before the next retry attempt.
fn delay(&self, attempt: u32) -> Duration;
/// Get the maximum number of attempts allowed.
fn max_attempts(&self) -> u32;
}
/// Exponential backoff retry policy.
///
/// Delays increase exponentially with each retry attempt,
/// up to a configured maximum delay.
#[derive(Debug, Clone)]
pub struct ExponentialBackoff {
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
}
impl ExponentialBackoff {
/// Create a new exponential backoff policy.
pub fn new(max_attempts: u32, base_delay: Duration, max_delay: Duration) -> Self {
Self {
max_attempts,
base_delay,
max_delay,
}
}
/// Create a policy with sensible defaults for rig communication.
pub fn default_rig() -> Self {
Self {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(2),
}
}
}
impl Default for ExponentialBackoff {
fn default() -> Self {
Self::default_rig()
}
}
impl RetryPolicy for ExponentialBackoff {
fn should_retry(&self, attempt: u32, error: &RigError) -> bool {
if attempt >= self.max_attempts {
return false;
}
// Only retry transient errors
error.is_transient()
}
fn delay(&self, attempt: u32) -> Duration {
let multiplier = 2u32.saturating_pow(attempt);
let delay = self.base_delay.saturating_mul(multiplier);
let capped = delay.min(self.max_delay);
apply_jitter(capped)
}
fn max_attempts(&self) -> u32 {
self.max_attempts
}
}
/// Fixed delay retry policy.
///
/// Uses a constant delay between retry attempts.
#[derive(Debug, Clone)]
pub struct FixedDelay {
max_attempts: u32,
delay: Duration,
}
impl FixedDelay {
/// Create a new fixed delay policy.
pub fn new(max_attempts: u32, delay: Duration) -> Self {
Self {
max_attempts,
delay,
}
}
}
impl RetryPolicy for FixedDelay {
fn should_retry(&self, attempt: u32, error: &RigError) -> bool {
attempt < self.max_attempts && error.is_transient()
}
fn delay(&self, _attempt: u32) -> Duration {
self.delay
}
fn max_attempts(&self) -> u32 {
self.max_attempts
}
}
/// No retry policy - operations fail immediately.
#[derive(Debug, Clone, Copy, Default)]
pub struct NoRetry;
impl RetryPolicy for NoRetry {
fn should_retry(&self, _attempt: u32, _error: &RigError) -> bool {
false
}
fn delay(&self, _attempt: u32) -> Duration {
Duration::ZERO
}
fn max_attempts(&self) -> u32 {
1
}
}
/// Policy for polling the rig for status updates.
pub trait PollingPolicy: Send + Sync {
/// Get the interval between polls.
fn interval(&self, transmitting: bool) -> Duration;
/// Determine if polling should occur given the current state.
fn should_poll(&self, transmitting: bool) -> bool;
}
/// Adaptive polling policy.
///
/// Uses different intervals depending on whether the rig is transmitting.
/// Polls more frequently during TX to track power/SWR meters.
#[derive(Debug, Clone)]
pub struct AdaptivePolling {
idle_interval: Duration,
active_interval: Duration,
}
impl AdaptivePolling {
/// Create a new adaptive polling policy.
pub fn new(idle_interval: Duration, active_interval: Duration) -> Self {
Self {
idle_interval,
active_interval,
}
}
/// Create a policy with sensible defaults for rig polling.
pub fn default_rig() -> Self {
Self {
idle_interval: Duration::from_millis(500),
active_interval: Duration::from_millis(100),
}
}
}
impl Default for AdaptivePolling {
fn default() -> Self {
Self::default_rig()
}
}
impl PollingPolicy for AdaptivePolling {
fn interval(&self, transmitting: bool) -> Duration {
if transmitting {
self.active_interval
} else {
self.idle_interval
}
}
fn should_poll(&self, _transmitting: bool) -> bool {
true
}
}
/// Fixed polling policy.
///
/// Uses a constant interval regardless of rig state.
#[derive(Debug, Clone)]
pub struct FixedPolling {
interval: Duration,
}
impl FixedPolling {
/// Create a new fixed polling policy.
pub fn new(interval: Duration) -> Self {
Self { interval }
}
}
impl PollingPolicy for FixedPolling {
fn interval(&self, _transmitting: bool) -> Duration {
self.interval
}
fn should_poll(&self, _transmitting: bool) -> bool {
true
}
}
/// No polling policy - disables automatic polling.
#[derive(Debug, Clone, Copy, Default)]
pub struct NoPolling;
impl PollingPolicy for NoPolling {
fn interval(&self, _transmitting: bool) -> Duration {
Duration::MAX
}
fn should_poll(&self, _transmitting: bool) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exponential_backoff_delays() {
let policy = ExponentialBackoff::new(5, Duration::from_millis(100), Duration::from_secs(1));
// Delays include ±25% jitter, so check they fall in the expected range.
let check = |attempt: u32, base_ms: u64| {
let d = policy.delay(attempt);
let lo = Duration::from_secs_f64(base_ms as f64 * 0.75 / 1000.0);
let hi = Duration::from_secs_f64(base_ms as f64 * 1.25 / 1000.0);
assert!(
d >= lo && d <= hi,
"attempt {}: {:?} not in [{:?}, {:?}]",
attempt,
d,
lo,
hi
);
};
check(0, 100);
check(1, 200);
check(2, 400);
check(3, 800);
// Should cap at max_delay (1s) before jitter
check(4, 1000);
check(5, 1000);
}
#[test]
fn test_exponential_backoff_jitter_varies() {
// Two calls should (almost always) produce different values,
// confirming jitter is applied.
let policy = ExponentialBackoff::new(5, Duration::from_millis(100), Duration::from_secs(1));
let d1 = policy.delay(2);
std::thread::sleep(Duration::from_micros(10));
let d2 = policy.delay(2);
// With nanosecond-based jitter they should differ; if not,
// the test is still valid — it just means the same instant was sampled.
let _ = (d1, d2); // no assertion — this is a smoke test
}
#[test]
fn test_exponential_backoff_should_retry() {
let policy = ExponentialBackoff::new(3, Duration::from_millis(100), Duration::from_secs(1));
let transient = RigError::timeout();
let fatal = RigError::not_supported("test");
assert!(policy.should_retry(0, &transient));
assert!(policy.should_retry(1, &transient));
assert!(policy.should_retry(2, &transient));
assert!(!policy.should_retry(3, &transient)); // exceeded max attempts
assert!(!policy.should_retry(0, &fatal)); // not transient
}
#[test]
fn test_fixed_delay() {
let policy = FixedDelay::new(3, Duration::from_millis(500));
assert_eq!(policy.delay(0), Duration::from_millis(500));
assert_eq!(policy.delay(1), Duration::from_millis(500));
assert_eq!(policy.delay(5), Duration::from_millis(500));
}
#[test]
fn test_adaptive_polling() {
let policy = AdaptivePolling::new(Duration::from_millis(500), Duration::from_millis(100));
assert_eq!(policy.interval(false), Duration::from_millis(500));
assert_eq!(policy.interval(true), Duration::from_millis(100));
assert!(policy.should_poll(false));
assert!(policy.should_poll(true));
}
#[test]
fn test_no_polling() {
let policy = NoPolling;
assert!(!policy.should_poll(false));
assert!(!policy.should_poll(true));
}
}
+370
View File
@@ -0,0 +1,370 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::future::Future;
use std::pin::Pin;
use serde::{Deserialize, Serialize};
use crate::radio::freq::{Band, Freq};
use crate::{DynResult, RigMode};
/// Alias to reduce type complexity in RigCat.
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, Deserialize, PartialEq, Eq)]
pub enum RigAccessMethod {
Serial { path: String, baud: u32 },
Tcp { addr: String },
}
/// Static info describing a rig backend.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigInfo {
pub manufacturer: String,
pub model: String,
pub revision: String,
pub capabilities: RigCapabilities,
pub access: RigAccessMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RigCapabilities {
#[serde(default = "default_min_freq_step_hz")]
pub min_freq_step_hz: u64,
pub supported_bands: Vec<Band>,
pub supported_modes: Vec<RigMode>,
pub num_vfos: usize,
pub lock: bool,
pub lockable: bool,
pub attenuator: bool,
pub preamp: bool,
pub rit: bool,
pub rpt: bool,
pub split: bool,
/// Backend supports transmit: PTT, power on/off, TX meters, TX audio.
pub tx: bool,
/// Backend supports get_tx_limit / set_tx_limit.
pub tx_limit: bool,
/// Backend supports toggle_vfo.
pub vfo_switch: bool,
/// Backend supports runtime filter adjustment (bandwidth).
pub filter_controls: bool,
/// Backend returns a meaningful RX signal strength value.
pub signal_meter: bool,
}
fn default_min_freq_step_hz() -> u64 {
1
}
/// Trait for rigs that can provide demodulated PCM audio.
pub trait AudioSource: Send + Sync {
/// Subscribe to demodulated PCM audio from the primary channel.
/// Returns a broadcast receiver that yields 20ms frames of mono f32 PCM.
fn subscribe_pcm(&self) -> tokio::sync::broadcast::Receiver<Vec<f32>>;
/// Subscribe to PCM from a specific backend channel when available.
/// Channel `0` is always the primary channel.
fn subscribe_pcm_channel(
&self,
channel_idx: usize,
) -> tokio::sync::broadcast::Receiver<Vec<f32>> {
if channel_idx == 0 {
self.subscribe_pcm()
} else {
let (tx, rx) = tokio::sync::broadcast::channel(1);
drop(tx);
rx
}
}
}
/// Common interface for rig backends.
pub trait Rig {
fn info(&self) -> &RigInfo;
}
/// Common CAT control operations any rig backend should implement.
///
/// This trait covers basic transceiver operations shared by all backends
/// (serial rigs, SDRs, etc.). SDR-specific controls live in the
/// [`RigSdr`] extension trait, accessible via [`as_sdr`](RigCat::as_sdr).
pub trait RigCat: Rig + Send {
fn get_status<'a>(&'a mut self) -> RigStatusFuture<'a>;
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 get_signal_strength<'a>(
&'a mut self,
) -> Pin<Box<dyn Future<Output = DynResult<u8>> + Send + 'a>>;
/// Return precise signal strength in dBm/dBFS as a float.
/// Backends with continuous measurements (e.g. SDR) override this
/// to bypass the coarse 0..15 quantisation of `get_signal_strength`.
fn get_signal_strength_db<'a>(
&'a mut self,
) -> Pin<Box<dyn Future<Output = Option<f64>> + Send + 'a>> {
Box::pin(std::future::ready(None))
}
fn get_tx_power<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<u8>> + 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 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 as_audio_source(&self) -> Option<&dyn AudioSource> {
None
}
/// Return a mutable reference to the SDR extension trait, if this
/// backend supports SDR-specific operations. Default: `None`.
fn as_sdr(&mut self) -> Option<&mut dyn RigSdr> {
None
}
/// Return an immutable reference to the SDR extension trait for
/// query-only operations (filter state, spectrum, RDS).
fn as_sdr_ref(&self) -> Option<&dyn RigSdr> {
None
}
}
/// SDR-specific extension operations.
///
/// Backends that support SDR features (center frequency, gain, AGC,
/// squelch, noise blanker, spectrum output, etc.) implement this trait.
/// Access it from a `dyn RigCat` via [`RigCat::as_sdr`].
///
/// All methods have default implementations returning "not supported"
/// or `None`, so backends need only override what they actually support.
pub trait RigSdr: Send {
fn set_center_freq<'a>(
&'a mut self,
_freq: Freq,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_center_freq"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_bandwidth<'a>(
&'a mut self,
_bandwidth_hz: u32,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_bandwidth"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_deemphasis<'a>(
&'a mut self,
_deemphasis_us: u32,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_wfm_deemphasis"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sdr_gain<'a>(
&'a mut self,
_gain_db: f64,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_gain"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sdr_lna_gain<'a>(
&'a mut self,
_gain_db: f64,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_lna_gain"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sdr_agc<'a>(
&'a mut self,
_enabled: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_agc"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sdr_squelch<'a>(
&'a mut self,
_enabled: bool,
_threshold_db: f64,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_squelch"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sdr_noise_blanker<'a>(
&'a mut self,
_enabled: bool,
_threshold: f64,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_stereo<'a>(
&'a mut self,
_enabled: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_wfm_stereo"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_denoise<'a>(
&'a mut self,
_level: state::WfmDenoiseLevel,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_wfm_denoise"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sam_stereo_width<'a>(
&'a mut self,
_width: f32,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sam_stereo_width"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sam_carrier_sync<'a>(
&'a mut self,
_enabled: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sam_carrier_sync"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
/// Return the current filter state if this backend supports filter controls.
fn filter_state(&self) -> Option<state::RigFilterState> {
None
}
/// Return the latest spectrum frame if this backend supports spectrum output.
fn get_spectrum(&self) -> Option<state::SpectrumData> {
None
}
/// Return the latest per-virtual-channel RDS data if supported.
fn get_vchan_rds(&self) -> Option<Vec<state::VchanRdsEntry>> {
None
}
}
/// Snapshot of a rig's status that every backend can expose.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigStatus {
pub freq: Freq,
pub mode: RigMode,
pub tx_en: bool,
pub vfo: Option<RigVfo>,
pub tx: Option<RigTxStatus>,
pub rx: Option<RigRxStatus>,
pub lock: Option<bool>,
}
/// Trait for presenting rig status in a backend-agnostic way.
pub trait RigStatusProvider {
fn status(&self) -> RigStatus;
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigVfo {
pub entries: Vec<RigVfoEntry>,
/// Index into `entries` for the active VFO, if known.
pub active: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigVfoEntry {
pub name: String,
pub freq: Freq,
pub mode: Option<RigMode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigTxStatus {
pub power: Option<u8>,
pub limit: Option<u8>,
pub swr: Option<f32>,
pub alc: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigRxStatus {
pub sig: Option<f64>,
}
/// Configurable control settings that can be pushed to the rig.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct RigControl {
pub enabled: Option<bool>,
pub lock: Option<bool>,
pub clar_hz: Option<i32>,
pub clar_on: Option<bool>,
pub rpt_offset_hz: Option<i32>,
pub ctcss_hz: Option<f32>,
pub dcs_code: Option<u16>,
}
+17
View File
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use tokio::sync::oneshot;
use crate::{RigCommand, RigResult, RigSnapshot};
/// Request sent to the rig task.
#[derive(Debug)]
pub struct RigRequest {
pub cmd: RigCommand,
pub respond_to: oneshot::Sender<RigResult<RigSnapshot>>,
/// When set, the remote client routes this request to the specified rig
/// instead of the globally selected rig. Used for per-rig rigctl listeners.
pub rig_id_override: Option<String>,
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::Serialize;
/// Error type returned by rig requests.
#[derive(Debug, Clone, Serialize)]
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 {
// Default to transient for backwards compatibility
RigError::transient(value)
}
}
impl From<&str> for RigError {
fn from(value: &str) -> Self {
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 {}
+496
View File
@@ -0,0 +1,496 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::{Deserialize, Serialize};
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,
#[serde(default)]
pub wefax_decode_enabled: bool,
#[serde(default)]
pub recorder_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,
#[serde(default, skip_serializing)]
pub wefax_decode_reset_seq: u64,
}
/// Simple transceiver state representation held by the rig task.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct RigState {
#[serde(skip_deserializing)]
pub rig_info: Option<RigInfo>,
pub status: RigStatus,
pub initialized: bool,
#[serde(skip_serializing, skip_deserializing)]
pub control: RigControl,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_callsign: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_build_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_latitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_longitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
/// Decoder enable/disable flags.
#[serde(flatten)]
pub decoders: DecoderConfig,
#[serde(default)]
pub cw_auto: bool,
#[serde(default)]
pub cw_wpm: u32,
#[serde(default)]
pub cw_tone_hz: u32,
/// Filter state for backends that support runtime filter adjustment.
/// Skipped in serde; flows into RigSnapshot via snapshot().
#[serde(skip)]
pub filter: Option<RigFilterState>,
/// Latest spectrum frame from SDR backends.
/// Skipped in serde (not part of persistent state); flows into RigSnapshot on demand.
#[serde(skip)]
pub spectrum: Option<SpectrumData>,
/// Latest virtual-channel RDS data from SDR backends.
/// Skipped in serde (not part of persistent state); flows into RigSnapshot on demand.
#[serde(skip)]
pub vchan_rds: Option<Vec<VchanRdsEntry>>,
/// Decoder reset sequence counters.
#[serde(flatten)]
pub reset_seqs: DecoderResetSeqs,
}
/// Mode supported by the rig.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum RigMode {
LSB,
USB,
CW,
CWR,
AM,
/// Synchronous AM (Stereo AM) — carrier-locked stereo demodulation.
SAM,
WFM,
FM,
AIS,
VDES,
DIG,
PKT,
Other(String),
}
impl Default for RigStatus {
fn default() -> Self {
Self {
freq: Freq { hz: 144_300_000 }, // 2m calling frequency
mode: RigMode::USB,
tx_en: false,
vfo: None,
tx: Some(RigTxStatus {
power: None,
limit: None,
swr: None,
alc: None,
}),
rx: Some(RigRxStatus { sig: None }),
lock: Some(false),
}
}
}
impl Default for RigControl {
fn default() -> Self {
Self {
rpt_offset_hz: None,
ctcss_hz: None,
dcs_code: None,
lock: Some(false),
clar_hz: None,
clar_on: None,
enabled: Some(false),
}
}
}
impl RigStatusProvider for RigState {
fn status(&self) -> RigStatus {
self.status.clone()
}
}
impl RigState {
/// Create uninitialized state with common defaults (client-side).
pub fn new_uninitialized() -> Self {
Self {
rig_info: None,
status: RigStatus::default(),
initialized: false,
control: RigControl::default(),
server_callsign: None,
server_version: None,
server_build_date: None,
server_latitude: None,
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
decoders: DecoderConfig::default(),
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
filter: None,
spectrum: None,
vchan_rds: None,
reset_seqs: DecoderResetSeqs::default(),
}
}
/// Create state with server metadata and initial freq/mode (server-side).
pub fn new_with_metadata(
callsign: Option<String>,
version: Option<String>,
build_date: Option<String>,
latitude: Option<f64>,
longitude: Option<f64>,
initial_freq_hz: u64,
initial_mode: RigMode,
) -> Self {
let mut state = Self::new_uninitialized();
state.server_callsign = callsign;
state.server_version = version;
state.server_build_date = build_date;
state.server_latitude = latitude;
state.server_longitude = longitude;
state.status.freq = Freq {
hz: initial_freq_hz,
};
state.apply_mode(initial_mode);
state
}
/// Convert snapshot to full state (remote client).
pub fn from_snapshot(snapshot: RigSnapshot) -> Self {
let lock = snapshot.status.lock;
Self {
rig_info: Some(snapshot.info),
status: snapshot.status,
initialized: snapshot.initialized,
control: RigControl {
rpt_offset_hz: None,
ctcss_hz: None,
dcs_code: None,
lock,
clar_hz: None,
clar_on: None,
enabled: snapshot.enabled,
},
server_callsign: snapshot.server_callsign,
server_version: snapshot.server_version,
server_build_date: snapshot.server_build_date,
server_latitude: snapshot.server_latitude,
server_longitude: snapshot.server_longitude,
pskreporter_status: snapshot.pskreporter_status,
aprs_is_status: snapshot.aprs_is_status,
decoders: snapshot.decoders,
cw_auto: snapshot.cw_auto,
cw_wpm: snapshot.cw_wpm,
cw_tone_hz: snapshot.cw_tone_hz,
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
reset_seqs: DecoderResetSeqs::default(),
}
}
pub fn band_name(&self) -> Option<String> {
self.rig_info.as_ref().and_then(|info| {
self.status
.freq
.band_name(&info.capabilities.supported_bands)
})
}
/// Produce an immutable snapshot suitable for sharing with clients.
pub fn snapshot(&self) -> Option<RigSnapshot> {
let info = self.rig_info.clone()?;
Some(RigSnapshot {
info,
status: self.status.clone(),
band: self.band_name(),
enabled: self.control.enabled,
initialized: self.initialized,
server_callsign: self.server_callsign.clone(),
server_version: self.server_version.clone(),
server_build_date: self.server_build_date.clone(),
server_latitude: self.server_latitude,
server_longitude: self.server_longitude,
pskreporter_status: self.pskreporter_status.clone(),
aprs_is_status: self.aprs_is_status.clone(),
decoders: self.decoders.clone(),
cw_auto: self.cw_auto,
cw_wpm: self.cw_wpm,
cw_tone_hz: self.cw_tone_hz,
filter: self.filter.clone(),
spectrum: self.spectrum.clone(),
vchan_rds: self.vchan_rds.clone(),
})
}
/// Apply a frequency change into the state.
pub fn apply_freq(&mut self, freq: crate::radio::freq::Freq) {
self.status.freq = freq;
}
/// Apply a mode change into the state.
pub fn apply_mode(&mut self, mode: RigMode) {
let cw_mode = matches!(mode, RigMode::CW | RigMode::CWR);
self.status.mode = mode;
if cw_mode {
self.decoders.cw_decode_enabled = true;
}
}
/// Apply a PTT change, resetting meters on TX off.
pub fn apply_ptt(&mut self, ptt: bool) {
self.status.tx_en = ptt;
self.status.lock = self.control.lock;
if !ptt {
if let Some(tx) = self.status.tx.as_mut() {
tx.power = Some(0);
tx.swr = Some(0.0);
}
}
}
}
/// Current filter/DSP state for backends that support runtime filter adjustment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigFilterState {
pub bandwidth_hz: u32,
pub cw_center_hz: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_gain_db: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_lna_gain_db: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_agc_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_squelch_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_squelch_threshold_db: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option<f64>,
#[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")]
pub wfm_stereo: bool,
#[serde(default)]
pub wfm_stereo_detected: bool,
#[serde(default = "default_wfm_denoise_level")]
pub wfm_denoise: WfmDenoiseLevel,
/// Co-Channel Interference level (0100 scale).
#[serde(default)]
pub wfm_cci: u8,
/// Adjacent Channel Interference level (0100 scale).
#[serde(default)]
pub wfm_aci: u8,
/// SAM stereo width (0.0 = mono, 1.0 = full stereo).
#[serde(default = "default_sam_stereo_width")]
pub sam_stereo_width: f32,
/// SAM carrier synchronization enabled.
#[serde(default = "default_sam_carrier_sync")]
pub sam_carrier_sync: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum WfmDenoiseLevel {
Off,
Auto,
Low,
Medium,
High,
}
fn default_wfm_deemphasis_us() -> u32 {
75
}
fn default_wfm_stereo() -> bool {
true
}
fn default_sam_stereo_width() -> f32 {
1.0
}
fn default_sam_carrier_sync() -> bool {
true
}
fn default_wfm_denoise_level() -> WfmDenoiseLevel {
WfmDenoiseLevel::Auto
}
/// Spectrum data from SDR backends (FFT magnitude over the full capture bandwidth).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpectrumData {
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
pub bins: Vec<f32>,
/// Centre frequency of the SDR capture in Hz.
pub center_hz: u64,
/// SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
pub sample_rate: u32,
/// Decoded Radio Data System state, when available for WFM.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rds: Option<RdsData>,
}
/// Live RDS metadata decoded from a WFM broadcast.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RdsData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub program_service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub radio_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub program_type_name_long: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pty: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pty_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub traffic_program: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub traffic_announcement: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub music: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stereo: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artificial_head: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compressed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_pty: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alternative_frequencies_hz: Option<Vec<u32>>,
}
/// RDS metadata snapshot for a virtual channel.
///
/// `PartialEq` intentionally ignores `signal_db` so that rapidly-changing
/// signal levels do not cause the main state snapshot to diff on every poll
/// cycle (signal_db flows through the spectrum SSE instead).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VchanRdsEntry {
/// Virtual channel UUID.
pub id: Uuid,
/// Latest RDS data, if decoded.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rds: Option<RdsData>,
/// Channel signal level in dBFS.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal_db: Option<f32>,
}
impl PartialEq for VchanRdsEntry {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.rds == other.rds
}
}
/// Read-only projection of state shared with clients.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RigSnapshot {
pub info: RigInfo,
pub status: RigStatus,
pub band: Option<String>,
pub enabled: Option<bool>,
pub initialized: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_callsign: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_build_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_latitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_longitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
/// Decoder enable/disable flags.
#[serde(flatten)]
pub decoders: DecoderConfig,
#[serde(default)]
pub cw_auto: bool,
#[serde(default)]
pub cw_wpm: u32,
#[serde(default)]
pub cw_tone_hz: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<RigFilterState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spectrum: Option<SpectrumData>,
/// Per-virtual-channel RDS snapshots, when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<VchanRdsEntry>>,
}
+152
View File
@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Virtual channel management trait and shared types.
//!
//! A *virtual channel* is an independent DSP slice within the capture bandwidth
//! of an SDR rig. Each has its own frequency offset, demodulation mode, and
//! PCM audio broadcast. Traditional (non-SDR) rigs do not support virtual
//! channels; virtual channel operations are not available for traditional rigs.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use uuid::Uuid;
use crate::rig::state::RigMode;
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
/// Snapshot of one virtual channel's state (HTTP-serialisable).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VChannelInfo {
/// Stable UUID identifier.
pub id: Uuid,
/// Display index in the ordered channel list (0 = primary).
pub index: usize,
/// Dial frequency in Hz.
pub freq_hz: u64,
/// Demodulation mode name (e.g. "USB", "FM").
pub mode: String,
/// `true` for the primary channel (index 0), which cannot be removed.
pub permanent: bool,
}
/// Errors returned by virtual channel management operations.
#[derive(Debug, Clone)]
pub enum VChanError {
/// The configured channel cap would be exceeded.
CapReached { max: usize },
/// The requested frequency lies outside the current SDR capture bandwidth.
OutOfBandwidth { half_span_hz: i64 },
/// No channel with the given UUID exists.
NotFound,
/// Attempted to remove the permanent primary channel.
Permanent,
}
impl std::fmt::Display for VChanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VChanError::CapReached { max } => {
write!(f, "virtual channel cap reached (max {})", max)
}
VChanError::OutOfBandwidth { half_span_hz } => write!(
f,
"frequency outside SDR capture bandwidth (±{} Hz)",
half_span_hz
),
VChanError::NotFound => write!(f, "virtual channel not found"),
VChanError::Permanent => write!(f, "cannot remove the primary channel"),
}
}
}
// ---------------------------------------------------------------------------
// Trait
// ---------------------------------------------------------------------------
/// Manages virtual DSP channels for an SDR rig.
///
/// Implementations are `Send + Sync` so the manager can be shared across
/// tokio tasks and actix-web handlers.
pub trait VirtualChannelManager: Send + Sync {
/// Add a new virtual channel tuned to `freq_hz` with `mode`.
///
/// Returns the new channel UUID and a PCM broadcast receiver that delivers
/// decoded audio frames for this channel.
fn add_channel(
&self,
freq_hz: u64,
mode: &RigMode,
) -> Result<(Uuid, broadcast::Receiver<Vec<f32>>), VChanError>;
/// Remove a virtual channel by UUID. The primary channel (index 0) cannot
/// be removed and returns `VChanError::Permanent`.
fn remove_channel(&self, id: Uuid) -> Result<(), VChanError>;
/// Update the dial frequency of an existing channel.
fn set_channel_freq(&self, id: Uuid, freq_hz: u64) -> Result<(), VChanError>;
/// Update the demodulation mode of an existing channel.
fn set_channel_mode(&self, id: Uuid, mode: &RigMode) -> Result<(), VChanError>;
/// Update the audio filter bandwidth of an existing channel.
fn set_channel_bandwidth(&self, id: Uuid, bandwidth_hz: u32) -> Result<(), VChanError>;
/// Subscribe to decoded PCM audio from a channel.
/// Returns `None` if the channel UUID does not exist.
fn subscribe_pcm(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<f32>>>;
/// Return a PCM receiver for an existing channel, or create a new channel
/// with the given `id`, `freq_hz`, and `mode` and subscribe to it.
///
/// Used by the audio-TCP server path where the client provides a stable UUID
/// (generated on the client side) so that both sides use the same identifier
/// without a separate round-trip to allocate a server UUID.
fn ensure_channel_pcm(
&self,
id: Uuid,
freq_hz: u64,
mode: &RigMode,
) -> Result<broadcast::Receiver<Vec<f32>>, VChanError>;
/// Return a PCM receiver for an existing hidden background-decode channel,
/// or create one if it does not exist.
///
/// Hidden background channels are not enumerated via `channels()` and do
/// not count against the normal virtual-channel cap.
fn ensure_background_channel_pcm(
&self,
id: Uuid,
freq_hz: u64,
mode: &RigMode,
) -> Result<broadcast::Receiver<Vec<f32>>, VChanError> {
self.ensure_channel_pcm(id, freq_hz, mode)
}
/// Return a snapshot of all channels in display order.
fn channels(&self) -> Vec<VChannelInfo>;
/// Maximum number of channels (including the primary channel).
fn max_channels(&self) -> usize;
/// Subscribe to server-side channel destruction events.
///
/// Returns a `broadcast::Receiver<Uuid>` that fires whenever the manager
/// destroys a channel (e.g. because it went out of the SDR capture
/// bandwidth). The default implementation returns an immediately-closed
/// receiver so non-SDR backends do not need to override this.
fn subscribe_destroyed(&self) -> broadcast::Receiver<Uuid> {
// Drop the sender immediately; the receiver will resolve to
// `Err(RecvError::Closed)` on first poll, signalling "no events".
broadcast::channel::<Uuid>(1).1
}
}
/// Convenience alias used in `RigHandle`.
pub type SharedVChanManager = Arc<dyn VirtualChannelManager>;