Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(()) })
|
||||
}
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 (0–100 scale).
|
||||
#[serde(default)]
|
||||
pub wfm_cci: u8,
|
||||
/// Adjacent Channel Interference level (0–100 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>>,
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user