[feat](trx-backend): VirtualChannelManager trait + SdrVirtualChannelManager impl
Add VirtualChannelManager trait in trx-core::vchan with types VChannelInfo, VChanError, and SharedVChanManager alias. Re-export from trx-backend::vchan. Implement SdrVirtualChannelManager in trx-backend-soapysdr: - Wraps Arc<SdrPipeline> + shared AtomicI64 center_hz - add_channel / remove_channel / set_channel_freq / set_channel_mode - Slot-stability: on remove, shifts pipeline_slot for surviving channels - update_center_hz: recomputes IF offsets for all virtual channels on retune - update_primary_meta: keeps channel-0 freq/mode in sync for API consumers Wire into SoapySdrRig (holds Arc<SdrVirtualChannelManager>, exposes channel_manager()), SdrPipeline (shared_center_hz AtomicI64), and RigHandle (vchan_manager: Option<SharedVChanManager>). main.rs extracts the manager before boxing the SDR rig and stores it in the handle. Add max_virtual_channels to SdrConfig (default 4, TOML-configurable). Add 5 unit tests: add, remove, permanent guard, cap, out-of-bandwidth. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
Generated
+208
-3
@@ -320,6 +320,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.102"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "audiopus_sys"
|
name = "audiopus_sys"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -941,10 +947,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 6.0.0",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -970,6 +989,15 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -1116,6 +1144,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1144,7 +1178,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1238,6 +1274,12 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.177"
|
||||||
@@ -1638,6 +1680,16 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "primal-check"
|
name = "primal-check"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -1680,6 +1732,12 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.5"
|
version = "0.8.5"
|
||||||
@@ -2407,6 +2465,7 @@ name = "trx-backend"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-serial",
|
"tokio-serial",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2414,6 +2473,7 @@ dependencies = [
|
|||||||
"trx-backend-ft817",
|
"trx-backend-ft817",
|
||||||
"trx-backend-soapysdr",
|
"trx-backend-soapysdr",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2450,6 +2510,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-rds",
|
"trx-rds",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2485,6 +2546,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2680,6 +2742,18 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2714,7 +2788,16 @@ version = "1.0.1+wasi-0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen",
|
"wit-bindgen 0.46.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen 0.51.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2776,6 +2859,40 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
@@ -3142,6 +3259,94 @@ version = "0.46.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ resolver = "2"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
tokio-serial = "5"
|
tokio-serial = "5"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
flate2 = { workspace = true }
|
flate2 = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod decode;
|
|||||||
pub mod math;
|
pub mod math;
|
||||||
pub mod radio;
|
pub mod radio;
|
||||||
pub mod rig;
|
pub mod rig;
|
||||||
|
pub mod vchan;
|
||||||
|
|
||||||
pub type DynResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
pub type DynResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! 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; their `RigHandle::vchan_manager` field will be `None`.
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
/// 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience alias used in `RigHandle`.
|
||||||
|
pub type SharedVChanManager = Arc<dyn VirtualChannelManager>;
|
||||||
@@ -345,6 +345,14 @@ pub struct SdrConfig {
|
|||||||
pub squelch: SdrSquelchConfig,
|
pub squelch: SdrSquelchConfig,
|
||||||
/// Virtual receiver channels (at least one required when SDR backend is active).
|
/// Virtual receiver channels (at least one required when SDR backend is active).
|
||||||
pub channels: Vec<SdrChannelConfig>,
|
pub channels: Vec<SdrChannelConfig>,
|
||||||
|
/// Maximum number of simultaneous virtual channels (including the primary).
|
||||||
|
/// Default: 4.
|
||||||
|
#[serde(default = "default_max_virtual_channels")]
|
||||||
|
pub max_virtual_channels: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_virtual_channels() -> usize {
|
||||||
|
4
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SdrConfig {
|
impl Default for SdrConfig {
|
||||||
@@ -357,6 +365,7 @@ impl Default for SdrConfig {
|
|||||||
gain: SdrGainConfig::default(),
|
gain: SdrGainConfig::default(),
|
||||||
squelch: SdrSquelchConfig::default(),
|
squelch: SdrSquelchConfig::default(),
|
||||||
channels: Vec::new(),
|
channels: Vec::new(),
|
||||||
|
max_virtual_channels: default_max_virtual_channels(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ mod tests {
|
|||||||
rig_tx,
|
rig_tx,
|
||||||
state_rx,
|
state_rx,
|
||||||
audio_port: 4531,
|
audio_port: 4531,
|
||||||
|
vchan_manager: None,
|
||||||
};
|
};
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("default".to_string(), handle);
|
map.insert("default".to_string(), handle);
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ type SdrRigBuildResult = DynResult<(
|
|||||||
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
tokio::sync::broadcast::Receiver<Vec<f32>>,
|
||||||
),
|
),
|
||||||
tokio::sync::broadcast::Receiver<Vec<num_complex::Complex<f32>>>,
|
tokio::sync::broadcast::Receiver<Vec<num_complex::Complex<f32>>>,
|
||||||
|
trx_core::vchan::SharedVChanManager,
|
||||||
)>;
|
)>;
|
||||||
|
|
||||||
type OptionalSdrRig = Option<Box<dyn trx_core::rig::RigCat>>;
|
type OptionalSdrRig = Option<Box<dyn trx_core::rig::RigCat>>;
|
||||||
@@ -348,6 +349,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
rig_cfg.sdr.squelch.threshold_db,
|
rig_cfg.sdr.squelch.threshold_db,
|
||||||
rig_cfg.sdr.squelch.hysteresis_db,
|
rig_cfg.sdr.squelch.hysteresis_db,
|
||||||
rig_cfg.sdr.squelch.tail_ms,
|
rig_cfg.sdr.squelch.tail_ms,
|
||||||
|
rig_cfg.sdr.max_virtual_channels,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let pcm_rx = sdr_rig.subscribe_pcm();
|
let pcm_rx = sdr_rig.subscribe_pcm();
|
||||||
@@ -363,11 +365,14 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
.position(|(_, mode, _, _)| matches!(mode, trx_core::rig::state::RigMode::VDES | trx_core::rig::state::RigMode::MARINE))
|
.position(|(_, mode, _, _)| matches!(mode, trx_core::rig::state::RigMode::VDES | trx_core::rig::state::RigMode::MARINE))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let vdes_iq = sdr_rig.subscribe_iq_channel(vdes_channel_idx);
|
let vdes_iq = sdr_rig.subscribe_iq_channel(vdes_channel_idx);
|
||||||
|
// Extract the virtual channel manager before the rig is consumed by Box.
|
||||||
|
let vchan_manager: trx_core::vchan::SharedVChanManager = sdr_rig.channel_manager();
|
||||||
Ok((
|
Ok((
|
||||||
Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>,
|
Box::new(sdr_rig) as Box<dyn trx_core::rig::RigCat>,
|
||||||
pcm_rx,
|
pcm_rx,
|
||||||
ais_pcm,
|
ais_pcm,
|
||||||
vdes_iq,
|
vdes_iq,
|
||||||
|
vchan_manager,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,13 +922,17 @@ async fn main() -> DynResult<()> {
|
|||||||
|
|
||||||
// Build SDR rig when applicable.
|
// Build SDR rig when applicable.
|
||||||
#[cfg(feature = "soapysdr")]
|
#[cfg(feature = "soapysdr")]
|
||||||
|
let mut sdr_vchan_manager: Option<trx_core::vchan::SharedVChanManager> = None;
|
||||||
|
#[cfg(feature = "soapysdr")]
|
||||||
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx, sdr_vdes_iq_rx): (
|
let (sdr_prebuilt_rig, sdr_pcm_rx, sdr_ais_pcm_rx, sdr_vdes_iq_rx): (
|
||||||
OptionalSdrRig,
|
OptionalSdrRig,
|
||||||
OptionalSdrPcmRx,
|
OptionalSdrPcmRx,
|
||||||
OptionalSdrAisPcmRx,
|
OptionalSdrAisPcmRx,
|
||||||
OptionalSdrVdesIqRx,
|
OptionalSdrVdesIqRx,
|
||||||
) = if rig_cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
) = if rig_cfg.rig.access.access_type.as_deref() == Some("sdr") {
|
||||||
let (rig, pcm_rx, ais_pcm_rx, vdes_iq_rx) = build_sdr_rig_from_instance(rig_cfg)?;
|
let (rig, pcm_rx, ais_pcm_rx, vdes_iq_rx, vchan_mgr) =
|
||||||
|
build_sdr_rig_from_instance(rig_cfg)?;
|
||||||
|
sdr_vchan_manager = Some(vchan_mgr);
|
||||||
(Some(rig), Some(pcm_rx), Some(ais_pcm_rx), Some(vdes_iq_rx))
|
(Some(rig), Some(pcm_rx), Some(ais_pcm_rx), Some(vdes_iq_rx))
|
||||||
} else {
|
} else {
|
||||||
(None, None, None, None)
|
(None, None, None, None)
|
||||||
@@ -1005,6 +1014,11 @@ async fn main() -> DynResult<()> {
|
|||||||
);
|
);
|
||||||
task_handles.extend(audio_handles);
|
task_handles.extend(audio_handles);
|
||||||
|
|
||||||
|
#[cfg(feature = "soapysdr")]
|
||||||
|
let vchan_manager_for_handle = sdr_vchan_manager;
|
||||||
|
#[cfg(not(feature = "soapysdr"))]
|
||||||
|
let vchan_manager_for_handle: Option<trx_core::vchan::SharedVChanManager> = None;
|
||||||
|
|
||||||
rig_handles.insert(
|
rig_handles.insert(
|
||||||
rig_cfg.id.clone(),
|
rig_cfg.id.clone(),
|
||||||
RigHandle {
|
RigHandle {
|
||||||
@@ -1013,6 +1027,7 @@ async fn main() -> DynResult<()> {
|
|||||||
rig_tx,
|
rig_tx,
|
||||||
state_rx,
|
state_rx,
|
||||||
audio_port: rig_cfg.audio.port,
|
audio_port: rig_cfg.audio.port,
|
||||||
|
vchan_manager: vchan_manager_for_handle,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
|
use trx_core::vchan::SharedVChanManager;
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
|
|
||||||
@@ -24,4 +25,6 @@ pub struct RigHandle {
|
|||||||
pub state_rx: watch::Receiver<RigState>,
|
pub state_rx: watch::Receiver<RigState>,
|
||||||
/// Per-rig audio listener TCP port.
|
/// Per-rig audio listener TCP port.
|
||||||
pub audio_port: u16,
|
pub audio_port: u16,
|
||||||
|
/// Virtual channel manager; `Some` only for SDR rigs.
|
||||||
|
pub vchan_manager: Option<SharedVChanManager>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ trx-backend-soapysdr = { path = "./trx-backend-soapysdr", optional = true }
|
|||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tokio-serial = { workspace = true }
|
tokio-serial = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use trx_core::rig::RigCat;
|
|||||||
use trx_core::DynResult;
|
use trx_core::DynResult;
|
||||||
|
|
||||||
mod dummy;
|
mod dummy;
|
||||||
|
pub mod vchan;
|
||||||
|
|
||||||
|
pub use vchan::{SharedVChanManager, VChanError, VChannelInfo, VirtualChannelManager};
|
||||||
|
|
||||||
#[cfg(feature = "ft450d")]
|
#[cfg(feature = "ft450d")]
|
||||||
use trx_backend_ft450d::Ft450d;
|
use trx_backend_ft450d::Ft450d;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
// Re-export the trait and types from trx-core so crates that depend on
|
||||||
|
// trx-backend can use them without a direct trx-core dependency.
|
||||||
|
pub use trx_core::vchan::{SharedVChanManager, VChanError, VChannelInfo, VirtualChannelManager};
|
||||||
@@ -13,6 +13,7 @@ trx-core = { path = "../../../trx-core" }
|
|||||||
trx-rds = { path = "../../../decoders/trx-rds" }
|
trx-rds = { path = "../../../decoders/trx-rds" }
|
||||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
num-complex = "0.4"
|
num-complex = "0.4"
|
||||||
rustfft = "6"
|
rustfft = "6"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod channel;
|
|||||||
mod filter;
|
mod filter;
|
||||||
mod spectrum;
|
mod spectrum;
|
||||||
|
|
||||||
|
use std::sync::atomic::AtomicI64;
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -98,6 +99,9 @@ pub struct SdrPipeline {
|
|||||||
/// Write `Some(gain_db)` here to adjust the hardware RX gain.
|
/// Write `Some(gain_db)` here to adjust the hardware RX gain.
|
||||||
/// The IQ read loop picks it up on the next iteration.
|
/// The IQ read loop picks it up on the next iteration.
|
||||||
pub gain_cmd: Arc<std::sync::Mutex<Option<f64>>>,
|
pub gain_cmd: Arc<std::sync::Mutex<Option<f64>>>,
|
||||||
|
/// Current hardware center frequency in Hz, kept in sync by `SoapySdrRig`.
|
||||||
|
/// Read by `SdrVirtualChannelManager` to validate and compute IF offsets.
|
||||||
|
pub shared_center_hz: Arc<AtomicI64>,
|
||||||
// Parameters cached here so `add_virtual_channel` can construct new
|
// Parameters cached here so `add_virtual_channel` can construct new
|
||||||
// `ChannelDsp` instances without needing to be passed them again.
|
// `ChannelDsp` instances without needing to be passed them again.
|
||||||
audio_sample_rate: u32,
|
audio_sample_rate: u32,
|
||||||
@@ -192,6 +196,7 @@ impl SdrPipeline {
|
|||||||
sdr_sample_rate,
|
sdr_sample_rate,
|
||||||
retune_cmd,
|
retune_cmd,
|
||||||
gain_cmd,
|
gain_cmd,
|
||||||
|
shared_center_hz: Arc::new(AtomicI64::new(0)),
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_channels: output_channels,
|
audio_channels: output_channels,
|
||||||
frame_duration_ms,
|
frame_duration_ms,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
pub mod demod;
|
pub mod demod;
|
||||||
pub mod dsp;
|
pub mod dsp;
|
||||||
pub mod real_iq_source;
|
pub mod real_iq_source;
|
||||||
|
pub mod vchan_impl;
|
||||||
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use trx_core::radio::freq::{Band, Freq};
|
use trx_core::radio::freq::{Band, Freq};
|
||||||
@@ -19,12 +21,14 @@ use trx_core::{DynResult, RigMode};
|
|||||||
|
|
||||||
const AIS_CHANNEL_SPACING_HZ: i64 = 50_000;
|
const AIS_CHANNEL_SPACING_HZ: i64 = 50_000;
|
||||||
|
|
||||||
|
pub use vchan_impl::SdrVirtualChannelManager;
|
||||||
|
|
||||||
/// RX-only backend for any SoapySDR-compatible device.
|
/// RX-only backend for any SoapySDR-compatible device.
|
||||||
pub struct SoapySdrRig {
|
pub struct SoapySdrRig {
|
||||||
info: RigInfo,
|
info: RigInfo,
|
||||||
freq: Freq,
|
freq: Freq,
|
||||||
mode: RigMode,
|
mode: RigMode,
|
||||||
pipeline: dsp::SdrPipeline,
|
pipeline: Arc<dsp::SdrPipeline>,
|
||||||
/// Index of the primary channel in `pipeline.channel_dsps`.
|
/// Index of the primary channel in `pipeline.channel_dsps`.
|
||||||
primary_channel_idx: usize,
|
primary_channel_idx: usize,
|
||||||
/// Current filter state of the primary channel (for filter_controls support).
|
/// Current filter state of the primary channel (for filter_controls support).
|
||||||
@@ -55,6 +59,8 @@ pub struct SoapySdrRig {
|
|||||||
squelch_threshold_db: f32,
|
squelch_threshold_db: f32,
|
||||||
/// Hidden AIS decoder channels (A and B) when available.
|
/// Hidden AIS decoder channels (A and B) when available.
|
||||||
ais_channel_indices: Option<(usize, usize)>,
|
ais_channel_indices: Option<(usize, usize)>,
|
||||||
|
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
||||||
|
channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoapySdrRig {
|
impl SoapySdrRig {
|
||||||
@@ -118,6 +124,7 @@ impl SoapySdrRig {
|
|||||||
squelch_threshold_db: f32,
|
squelch_threshold_db: f32,
|
||||||
squelch_hysteresis_db: f32,
|
squelch_hysteresis_db: f32,
|
||||||
squelch_tail_ms: u32,
|
squelch_tail_ms: u32,
|
||||||
|
max_virtual_channels: usize,
|
||||||
) -> DynResult<Self> {
|
) -> DynResult<Self> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
||||||
@@ -184,7 +191,7 @@ impl SoapySdrRig {
|
|||||||
(squelch_tail_ms as f64 / block_ms).ceil().max(0.0) as u32
|
(squelch_tail_ms as f64 / block_ms).ceil().max(0.0) as u32
|
||||||
};
|
};
|
||||||
|
|
||||||
let pipeline = dsp::SdrPipeline::start(
|
let pipeline = Arc::new(dsp::SdrPipeline::start(
|
||||||
iq_source,
|
iq_source,
|
||||||
sdr_sample_rate,
|
sdr_sample_rate,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
@@ -199,7 +206,7 @@ impl SoapySdrRig {
|
|||||||
tail_blocks: squelch_tail_blocks,
|
tail_blocks: squelch_tail_blocks,
|
||||||
},
|
},
|
||||||
&all_channels,
|
&all_channels,
|
||||||
);
|
));
|
||||||
|
|
||||||
let info = RigInfo {
|
let info = RigInfo {
|
||||||
manufacturer: "SoapySDR".to_string(),
|
manufacturer: "SoapySDR".to_string(),
|
||||||
@@ -254,6 +261,18 @@ impl SoapySdrRig {
|
|||||||
|
|
||||||
let spectrum_buf = pipeline.spectrum_buf.clone();
|
let spectrum_buf = pipeline.spectrum_buf.clone();
|
||||||
let retune_cmd = pipeline.retune_cmd.clone();
|
let retune_cmd = pipeline.retune_cmd.clone();
|
||||||
|
// Initial center_hz stored in the pipeline's shared atomic so the
|
||||||
|
// virtual channel manager can read it without a reference to SoapySdrRig.
|
||||||
|
pipeline
|
||||||
|
.shared_center_hz
|
||||||
|
.store(hardware_center_hz, Ordering::Relaxed);
|
||||||
|
// Fixed slots: user-configured channels + 2 AIS channels.
|
||||||
|
let fixed_slot_count = all_channels.len();
|
||||||
|
let channel_manager = Arc::new(vchan_impl::SdrVirtualChannelManager::new(
|
||||||
|
pipeline.clone(),
|
||||||
|
fixed_slot_count,
|
||||||
|
max_virtual_channels,
|
||||||
|
));
|
||||||
|
|
||||||
let rig = Self {
|
let rig = Self {
|
||||||
info,
|
info,
|
||||||
@@ -275,6 +294,7 @@ impl SoapySdrRig {
|
|||||||
squelch_enabled,
|
squelch_enabled,
|
||||||
squelch_threshold_db,
|
squelch_threshold_db,
|
||||||
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
||||||
|
channel_manager,
|
||||||
};
|
};
|
||||||
rig.apply_ais_channel_activity();
|
rig.apply_ais_channel_activity();
|
||||||
Ok(rig)
|
Ok(rig)
|
||||||
@@ -303,9 +323,16 @@ impl SoapySdrRig {
|
|||||||
-65.0, // squelch_threshold_db
|
-65.0, // squelch_threshold_db
|
||||||
3.0, // squelch_hysteresis_db
|
3.0, // squelch_hysteresis_db
|
||||||
180, // squelch_tail_ms
|
180, // squelch_tail_ms
|
||||||
|
4, // max_virtual_channels
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the virtual channel manager for this SDR rig.
|
||||||
|
/// Used by `RigHandle` to expose the channel API.
|
||||||
|
pub fn channel_manager(&self) -> trx_core::vchan::SharedVChanManager {
|
||||||
|
self.channel_manager.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn update_ais_channel_offsets(&self) {
|
fn update_ais_channel_offsets(&self) {
|
||||||
let Some((ais_a_idx, ais_b_idx)) = self.ais_channel_indices else {
|
let Some((ais_a_idx, ais_b_idx)) = self.ais_channel_indices else {
|
||||||
return;
|
return;
|
||||||
@@ -354,85 +381,11 @@ impl SoapySdrRig {
|
|||||||
self.center_hz
|
self.center_hz
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Half of the SDR capture bandwidth (Hz). A virtual channel's dial
|
/// Half of the SDR capture bandwidth (Hz).
|
||||||
/// frequency must stay within `center_hz ± half_span_hz`.
|
|
||||||
pub fn half_span_hz(&self) -> i64 {
|
pub fn half_span_hz(&self) -> i64 {
|
||||||
i64::from(self.pipeline.sdr_sample_rate) / 2
|
i64::from(self.pipeline.sdr_sample_rate) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allocate a new virtual DSP channel within the current SDR capture
|
|
||||||
/// bandwidth. Returns `None` if `freq_hz` is outside the capture span.
|
|
||||||
///
|
|
||||||
/// The returned senders can be subscribed to for PCM audio frames. The
|
|
||||||
/// `pipeline_slot` index identifies the slot for future
|
|
||||||
/// `virtual_channel_set_freq`, `virtual_channel_set_mode`, and
|
|
||||||
/// `virtual_channel_remove` calls.
|
|
||||||
pub fn virtual_channel_add(
|
|
||||||
&self,
|
|
||||||
freq_hz: u64,
|
|
||||||
mode: &RigMode,
|
|
||||||
bandwidth_hz: u32,
|
|
||||||
fir_taps: usize,
|
|
||||||
) -> Option<(
|
|
||||||
tokio::sync::broadcast::Sender<Vec<f32>>,
|
|
||||||
tokio::sync::broadcast::Sender<Vec<num_complex::Complex<f32>>>,
|
|
||||||
usize,
|
|
||||||
)> {
|
|
||||||
let channel_if_hz = freq_hz as i64 - self.center_hz;
|
|
||||||
if channel_if_hz.unsigned_abs() as i64 > self.half_span_hz() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (pcm_tx, iq_tx) =
|
|
||||||
self.pipeline
|
|
||||||
.add_virtual_channel(channel_if_hz as f64, mode, bandwidth_hz, fir_taps);
|
|
||||||
let slot = self
|
|
||||||
.pipeline
|
|
||||||
.channel_dsps
|
|
||||||
.read()
|
|
||||||
.unwrap()
|
|
||||||
.len()
|
|
||||||
.saturating_sub(1);
|
|
||||||
Some((pcm_tx, iq_tx, slot))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a virtual channel at the given pipeline slot index.
|
|
||||||
/// Returns `false` if the slot is out of range.
|
|
||||||
pub fn virtual_channel_remove(&self, pipeline_slot: usize) -> bool {
|
|
||||||
self.pipeline.remove_virtual_channel(pipeline_slot)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the dial frequency of a virtual channel.
|
|
||||||
/// Returns `false` if the slot is out of range or the freq is outside
|
|
||||||
/// the current capture bandwidth.
|
|
||||||
pub fn virtual_channel_set_freq(&self, pipeline_slot: usize, freq_hz: u64) -> bool {
|
|
||||||
let channel_if_hz = freq_hz as i64 - self.center_hz;
|
|
||||||
if channel_if_hz.unsigned_abs() as i64 > self.half_span_hz() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
|
||||||
if let Some(dsp_arc) = dsps.get(pipeline_slot) {
|
|
||||||
dsp_arc
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.set_channel_if_hz(channel_if_hz as f64);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the demodulation mode of a virtual channel.
|
|
||||||
/// Returns `false` if the slot is out of range.
|
|
||||||
pub fn virtual_channel_set_mode(&self, pipeline_slot: usize, mode: &RigMode) -> bool {
|
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
|
||||||
if let Some(dsp_arc) = dsps.get(pipeline_slot) {
|
|
||||||
dsp_arc.lock().unwrap().set_mode(mode);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe_iq_channel(
|
pub fn subscribe_iq_channel(
|
||||||
&self,
|
&self,
|
||||||
channel_idx: usize,
|
channel_idx: usize,
|
||||||
@@ -521,6 +474,7 @@ impl RigCat for SoapySdrRig {
|
|||||||
if let Ok(mut cmd) = self.retune_cmd.lock() {
|
if let Ok(mut cmd) = self.retune_cmd.lock() {
|
||||||
*cmd = Some(hardware_hz as f64);
|
*cmd = Some(hardware_hz as f64);
|
||||||
}
|
}
|
||||||
|
self.channel_manager.update_center_hz(hardware_hz);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -549,6 +503,7 @@ impl RigCat for SoapySdrRig {
|
|||||||
if let Ok(mut cmd) = self.retune_cmd.lock() {
|
if let Ok(mut cmd) = self.retune_cmd.lock() {
|
||||||
*cmd = Some(self.center_hz as f64);
|
*cmd = Some(self.center_hz as f64);
|
||||||
}
|
}
|
||||||
|
self.channel_manager.update_center_hz(self.center_hz);
|
||||||
{
|
{
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
//! Concrete `VirtualChannelManager` implementation for SoapySDR rigs.
|
||||||
|
//!
|
||||||
|
//! `SdrVirtualChannelManager` wraps an `Arc<SdrPipeline>` and maintains a list
|
||||||
|
//! of managed channel entries. The primary channel (pipeline slot 0) is always
|
||||||
|
//! present and marked permanent; additional virtual channels are appended
|
||||||
|
//! dynamically.
|
||||||
|
//!
|
||||||
|
//! ## Slot stability
|
||||||
|
//!
|
||||||
|
//! Virtual channels occupy pipeline slots `fixed_slot_count..`. When a channel
|
||||||
|
//! at slot *K* is removed, `Vec::remove(K)` shifts all entries K+1..end left by
|
||||||
|
//! one; the manager updates every surviving entry's `pipeline_slot` accordingly.
|
||||||
|
//!
|
||||||
|
//! ## Center-frequency updates
|
||||||
|
//!
|
||||||
|
//! When the hardware retunes (changing `center_hz`), all channel IF offsets must
|
||||||
|
//! be recomputed. The rig calls `update_center_hz()` after every retune; this
|
||||||
|
//! updates every `ChannelDsp` in a single write-locked pass.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use num_complex::Complex;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use trx_core::rig::state::RigMode;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dsp::{SdrPipeline, VirtualSquelchConfig};
|
||||||
|
use trx_core::vchan::{VChanError, VChannelInfo, VirtualChannelManager};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default DSP parameters for virtual channels
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_FIR_TAPS: usize = 64;
|
||||||
|
|
||||||
|
fn default_bandwidth_hz(mode: &RigMode) -> u32 {
|
||||||
|
match mode {
|
||||||
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
|
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||||
|
RigMode::AM => 9_000,
|
||||||
|
RigMode::FM => 12_500,
|
||||||
|
RigMode::WFM => 180_000,
|
||||||
|
RigMode::PKT | RigMode::AIS => 25_000,
|
||||||
|
RigMode::VDES | RigMode::MARINE => 100_000,
|
||||||
|
RigMode::Other(_) => 3_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal channel record
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ManagedChannel {
|
||||||
|
id: Uuid,
|
||||||
|
freq_hz: u64,
|
||||||
|
mode: RigMode,
|
||||||
|
/// `broadcast::Sender` kept alive so new subscribers can join at any time.
|
||||||
|
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
|
/// IQ tap sender (kept alive; external consumers may subscribe).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
|
||||||
|
/// Index of this channel in `pipeline.channel_dsps`.
|
||||||
|
pipeline_slot: usize,
|
||||||
|
/// True only for the primary channel; prevents removal.
|
||||||
|
permanent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SdrVirtualChannelManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct SdrVirtualChannelManager {
|
||||||
|
pipeline: Arc<SdrPipeline>,
|
||||||
|
/// Current SDR hardware center frequency, updated on every retune.
|
||||||
|
center_hz: Arc<AtomicI64>,
|
||||||
|
/// Pipeline slots 0..fixed_slot_count are reserved (primary + AIS).
|
||||||
|
/// Virtual channels occupy slots fixed_slot_count and above.
|
||||||
|
fixed_slot_count: usize,
|
||||||
|
/// Maximum total channels including the primary (enforced on `add_channel`).
|
||||||
|
max_total: usize,
|
||||||
|
channels: RwLock<Vec<ManagedChannel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SdrVirtualChannelManager {
|
||||||
|
/// Create a new manager.
|
||||||
|
///
|
||||||
|
/// - `pipeline`: shared reference to the running `SdrPipeline`.
|
||||||
|
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
|
||||||
|
/// i.e. the index of the first slot available for virtual channels.
|
||||||
|
/// - `max_total`: maximum total channels including primary (e.g. 4).
|
||||||
|
pub fn new(
|
||||||
|
pipeline: Arc<SdrPipeline>,
|
||||||
|
fixed_slot_count: usize,
|
||||||
|
max_total: usize,
|
||||||
|
) -> Self {
|
||||||
|
// Seed the channel list with a synthetic primary-channel entry.
|
||||||
|
// We use the first PCM sender from the pipeline (index 0).
|
||||||
|
let primary_pcm_tx = pipeline
|
||||||
|
.pcm_senders
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| broadcast::channel::<Vec<f32>>(1).0);
|
||||||
|
let primary_iq_tx = pipeline
|
||||||
|
.iq_senders
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| broadcast::channel::<Vec<Complex<f32>>>(1).0);
|
||||||
|
|
||||||
|
let primary = ManagedChannel {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
|
||||||
|
mode: RigMode::USB,
|
||||||
|
pcm_tx: primary_pcm_tx,
|
||||||
|
iq_tx: primary_iq_tx,
|
||||||
|
pipeline_slot: 0,
|
||||||
|
permanent: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
center_hz: pipeline.shared_center_hz.clone(),
|
||||||
|
pipeline,
|
||||||
|
fixed_slot_count,
|
||||||
|
max_total: max_total.max(1),
|
||||||
|
channels: RwLock::new(vec![primary]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn half_span_hz(&self) -> i64 {
|
||||||
|
i64::from(self.pipeline.sdr_sample_rate) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by `SoapySdrRig` whenever the hardware center frequency changes.
|
||||||
|
/// Recomputes the IF offset for every virtual channel.
|
||||||
|
pub fn update_center_hz(&self, new_center_hz: i64) {
|
||||||
|
self.center_hz.store(new_center_hz, Ordering::Relaxed);
|
||||||
|
let channels = self.channels.read().unwrap();
|
||||||
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
|
for ch in channels.iter().filter(|c| !c.permanent) {
|
||||||
|
let new_if_hz = ch.freq_hz as i64 - new_center_hz;
|
||||||
|
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
||||||
|
dsp_arc
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.set_channel_if_hz(new_if_hz as f64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the primary channel's freq/mode metadata (called by SoapySdrRig
|
||||||
|
/// on SetFreq/SetMode so channel-0 info stays current for API consumers).
|
||||||
|
pub fn update_primary_meta(&self, freq_hz: u64, mode: &RigMode) {
|
||||||
|
let mut channels = self.channels.write().unwrap();
|
||||||
|
if let Some(ch) = channels.first_mut() {
|
||||||
|
ch.freq_hz = freq_hz;
|
||||||
|
ch.mode = mode.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VirtualChannelManager for SdrVirtualChannelManager {
|
||||||
|
fn add_channel(
|
||||||
|
&self,
|
||||||
|
freq_hz: u64,
|
||||||
|
mode: &RigMode,
|
||||||
|
) -> Result<(Uuid, broadcast::Receiver<Vec<f32>>), VChanError> {
|
||||||
|
let half_span = self.half_span_hz();
|
||||||
|
let center = self.center_hz.load(Ordering::Relaxed);
|
||||||
|
let if_hz = freq_hz as i64 - center;
|
||||||
|
if if_hz.unsigned_abs() as i64 > half_span {
|
||||||
|
return Err(VChanError::OutOfBandwidth {
|
||||||
|
half_span_hz: half_span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut channels = self.channels.write().unwrap();
|
||||||
|
if channels.len() >= self.max_total {
|
||||||
|
return Err(VChanError::CapReached { max: self.max_total });
|
||||||
|
}
|
||||||
|
|
||||||
|
let bandwidth_hz = default_bandwidth_hz(mode);
|
||||||
|
let (pcm_tx, iq_tx) =
|
||||||
|
self.pipeline
|
||||||
|
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz, DEFAULT_FIR_TAPS);
|
||||||
|
|
||||||
|
let pipeline_slot = self
|
||||||
|
.pipeline
|
||||||
|
.channel_dsps
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.len()
|
||||||
|
.saturating_sub(1);
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let pcm_rx = pcm_tx.subscribe();
|
||||||
|
channels.push(ManagedChannel {
|
||||||
|
id,
|
||||||
|
freq_hz,
|
||||||
|
mode: mode.clone(),
|
||||||
|
pcm_tx,
|
||||||
|
iq_tx,
|
||||||
|
pipeline_slot,
|
||||||
|
permanent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((id, pcm_rx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_channel(&self, id: Uuid) -> Result<(), VChanError> {
|
||||||
|
let mut channels = self.channels.write().unwrap();
|
||||||
|
let pos = channels
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.id == id)
|
||||||
|
.ok_or(VChanError::NotFound)?;
|
||||||
|
|
||||||
|
if channels[pos].permanent {
|
||||||
|
return Err(VChanError::Permanent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = channels[pos].pipeline_slot;
|
||||||
|
self.pipeline.remove_virtual_channel(slot);
|
||||||
|
channels.remove(pos);
|
||||||
|
|
||||||
|
// Shift pipeline_slot for all channels that were after the removed one.
|
||||||
|
for ch in channels.iter_mut().filter(|c| c.pipeline_slot > slot) {
|
||||||
|
ch.pipeline_slot -= 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_channel_freq(&self, id: Uuid, freq_hz: u64) -> Result<(), VChanError> {
|
||||||
|
let half_span = self.half_span_hz();
|
||||||
|
let center = self.center_hz.load(Ordering::Relaxed);
|
||||||
|
let if_hz = freq_hz as i64 - center;
|
||||||
|
if if_hz.unsigned_abs() as i64 > half_span {
|
||||||
|
return Err(VChanError::OutOfBandwidth {
|
||||||
|
half_span_hz: half_span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut channels = self.channels.write().unwrap();
|
||||||
|
let ch = channels
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == id)
|
||||||
|
.ok_or(VChanError::NotFound)?;
|
||||||
|
|
||||||
|
ch.freq_hz = freq_hz;
|
||||||
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
|
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
||||||
|
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_channel_mode(&self, id: Uuid, mode: &RigMode) -> Result<(), VChanError> {
|
||||||
|
let mut channels = self.channels.write().unwrap();
|
||||||
|
let ch = channels
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == id)
|
||||||
|
.ok_or(VChanError::NotFound)?;
|
||||||
|
|
||||||
|
ch.mode = mode.clone();
|
||||||
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
|
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
||||||
|
dsp_arc.lock().unwrap().set_mode(mode);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe_pcm(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<f32>>> {
|
||||||
|
let channels = self.channels.read().unwrap();
|
||||||
|
channels
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == id)
|
||||||
|
.map(|c| c.pcm_tx.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channels(&self) -> Vec<VChannelInfo> {
|
||||||
|
let channels = self.channels.read().unwrap();
|
||||||
|
channels
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, ch)| VChannelInfo {
|
||||||
|
id: ch.id,
|
||||||
|
index: idx,
|
||||||
|
freq_hz: ch.freq_hz,
|
||||||
|
mode: format!("{:?}", ch.mode),
|
||||||
|
permanent: ch.permanent,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_channels(&self) -> usize {
|
||||||
|
self.max_total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dsp::{MockIqSource, SdrPipeline};
|
||||||
|
|
||||||
|
fn make_pipeline() -> Arc<SdrPipeline> {
|
||||||
|
Arc::new(SdrPipeline::start(
|
||||||
|
Box::new(MockIqSource),
|
||||||
|
1_920_000,
|
||||||
|
48_000,
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
75,
|
||||||
|
true,
|
||||||
|
VirtualSquelchConfig::default(),
|
||||||
|
&[(0.0, RigMode::USB, 3_000, 64)],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_list() {
|
||||||
|
let p = make_pipeline();
|
||||||
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||||
|
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
|
||||||
|
mgr.update_center_hz(14_100_000);
|
||||||
|
assert_eq!(mgr.channels().len(), 1); // primary only
|
||||||
|
|
||||||
|
let (id, _rx) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
|
assert_eq!(mgr.channels().len(), 2);
|
||||||
|
|
||||||
|
let ch = mgr.channels().into_iter().find(|c| c.id == id).unwrap();
|
||||||
|
assert_eq!(ch.freq_hz, 14_074_000);
|
||||||
|
assert!(!ch.permanent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_virtual_channel() {
|
||||||
|
let p = make_pipeline();
|
||||||
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||||
|
mgr.update_center_hz(14_100_000);
|
||||||
|
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
|
mgr.remove_channel(id).unwrap();
|
||||||
|
assert_eq!(mgr.channels().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_remove_primary() {
|
||||||
|
let p = make_pipeline();
|
||||||
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||||
|
let primary_id = mgr.channels()[0].id;
|
||||||
|
let err = mgr.remove_channel(primary_id).unwrap_err();
|
||||||
|
assert!(matches!(err, VChanError::Permanent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_enforced() {
|
||||||
|
let p = make_pipeline();
|
||||||
|
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 virtual max
|
||||||
|
mgr.update_center_hz(14_100_000);
|
||||||
|
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
|
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
|
||||||
|
assert!(matches!(err, VChanError::CapReached { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_bandwidth() {
|
||||||
|
let p = make_pipeline();
|
||||||
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||||
|
// center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out
|
||||||
|
let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
|
||||||
|
assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user