[feat](trx-protocol): add centralised decoder registry

Add DECODER_REGISTRY in trx-protocol::decoders as the single source of
truth for all decoder metadata (activation mode, supported rig modes,
background-decode capability). Replace duplicated resolver functions in
background_decode.rs and sse.rs with shared resolve_bookmark_decoders().
Add GET /decoders endpoint to expose the registry to the frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-30 20:47:12 +02:00
parent 5c43bac42b
commit e6dbfd1edb
17 changed files with 677 additions and 91 deletions
@@ -3241,22 +3241,22 @@ function render(update) {
const wsprStatus = document.getElementById("wspr-status");
setModeBoundDecodeStatus(
aisStatus,
["AIS", "FM", "PKT"],
"Select AIS or FM mode to decode",
["AIS"],
"Select AIS mode to decode",
"Connected, listening for packets",
);
if (window.updateAisBar) window.updateAisBar();
setModeBoundDecodeStatus(
vdesStatus,
["VDES", "FM"],
"Select VDES or FM mode to decode",
["VDES"],
"Select VDES mode to decode",
"Connected, listening for bursts",
);
if (window.updateVdesBar) window.updateVdesBar();
setModeBoundDecodeStatus(
aprsStatus,
["PKT", "FM"],
"Select FM mode to decode",
["PKT"],
"Select PKT mode to decode",
"Connected, listening for packets",
);
if (window.updateAprsBar) window.updateAprsBar();
@@ -8903,10 +8903,10 @@ function updateDecodeStatus(text) {
const ft8 = document.getElementById("ft8-status");
const ft4 = document.getElementById("ft4-status");
const ft2 = document.getElementById("ft2-status");
setModeBoundDecodeStatus(ais, ["AIS", "FM", "PKT"], "Select AIS or FM mode to decode", text);
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text;
setModeBoundDecodeStatus(vdes, ["VDES", "FM"], "Select VDES or FM mode to decode", vdesText);
setModeBoundDecodeStatus(aprs, ["PKT", "FM"], "Select FM mode to decode", text);
setModeBoundDecodeStatus(vdes, ["VDES"], "Select VDES mode to decode", vdesText);
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
const cwText = text === "Connected, listening for packets" ? "Connected, listening for CW" : text;
setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText);
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
@@ -449,13 +449,14 @@
<input type="number" id="bm-freq" class="status-input" required min="0" placeholder="e.g. 7074000" />
</label>
<label class="bm-label">Mode
<input type="text" id="bm-mode" class="status-input" list="bm-mode-list" required placeholder="e.g. USB" />
<input type="text" id="bm-mode" class="status-input" list="bm-mode-list" required placeholder="e.g. DIG" />
<datalist id="bm-mode-list">
<option value="LSB">
<option value="USB">
<option value="AM">
<option value="SAM">
<option value="FM">
<option value="DIG">
<option value="CW">
<option value="WFM">
</datalist>
@@ -560,25 +561,25 @@
<div class="plugin-item">
<strong>FT8 Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Decodes FT8 messages from RX audio (USB only, toggle required).
Decodes FT8 messages from RX audio (DIG/USB only, toggle required).
</div>
</div>
<div class="plugin-item">
<strong>FT4 Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Decodes FT4 messages from RX audio (USB only, toggle required). 7.5-second slots.
Decodes FT4 messages from RX audio (DIG/USB only, toggle required). 7.5-second slots.
</div>
</div>
<div class="plugin-item">
<strong>FT2 Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Decodes FT2 messages from RX audio (USB only, toggle required). 3.75-second slots.
Decodes FT2 messages from RX audio (DIG/USB only, toggle required). 3.75-second slots.
</div>
</div>
<div class="plugin-item">
<strong>WSPR Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Decodes WSPR messages from RX audio (USB only, toggle required).
Decodes WSPR messages from RX audio (DIG/USB only, toggle required).
</div>
</div>
<div class="plugin-item">
@@ -322,8 +322,7 @@ function renderAprsHistory() {
function updateAprsBar() {
if (!aprsBarOverlay) return;
const modeVal = (document.getElementById("mode")?.value || "").toUpperCase();
const isPkt = modeVal === "PKT" || modeVal === "FM";
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
@@ -102,7 +102,7 @@
if (supported.length > 0) return supported;
const mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
if (mode === "AIS") return ["ais"];
if (mode === "PKT" || mode === "FM") return ["aprs"];
if (mode === "PKT") return ["aprs"];
return supported;
}
@@ -428,9 +428,9 @@ async function bmApply(bm) {
await postPath("/set_freq?hz=" + bm.freq_hz);
}
})();
// Decoder toggles (USB / DIG / FM / PKT modes) — also fire-and-forget.
// Decoder toggles (DIG / FM modes) — also fire-and-forget.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const decoderMode = bm.mode === "USB" || bm.mode === "DIG" || bm.mode === "FM" || bm.mode === "PKT";
const decoderMode = bm.mode === "DIG" || bm.mode === "FM";
const decoderPromise = (hasDecoders && decoderMode) ? (async () => {
const statusResp = await fetch("/status");
if (statusResp.ok) {
@@ -20,6 +20,15 @@ use trx_frontend::FrontendRuntimeContext;
use super::{gzip_bytes, send_command, RemoteQuery};
// ============================================================================
// Decoder registry
// ============================================================================
#[get("/decoders")]
pub async fn decoder_registry() -> impl Responder {
HttpResponse::Ok().json(trx_protocol::DECODER_REGISTRY)
}
// ============================================================================
// Decode history types and helpers
// ============================================================================
@@ -562,6 +562,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(sse::events)
.service(sse::spectrum)
// Decoder endpoints
.service(decoder::decoder_registry)
.service(decoder::decode_history)
.service(decoder::decode_events)
.service(decoder::toggle_aprs_decode)
@@ -135,30 +135,11 @@ fn sync_scheduler_vchannels(
}
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
let mut out = Vec::new();
for decoder in bookmark
.decoders
.iter()
.map(|item| item.trim().to_ascii_lowercase())
{
if matches!(
decoder.as_str(),
"aprs" | "ais" | "ft8" | "ft4" | "ft2" | "wspr" | "hf-aprs"
) && !out.iter().any(|existing| existing == &decoder)
{
out.push(decoder);
}
}
if !out.is_empty() {
return out;
}
match bookmark.mode.trim().to_ascii_uppercase().as_str() {
"AIS" => vec!["ais".to_string()],
"PKT" => vec!["aprs".to_string()],
_ => Vec::new(),
}
trx_protocol::decoders::resolve_bookmark_decoders(
&bookmark.decoders,
&bookmark.mode,
true,
)
}
// ============================================================================
@@ -22,7 +22,7 @@ use crate::server::bookmarks::{Bookmark, BookmarkStoreMap};
use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager};
use crate::server::vchan::{ClientChannel, ClientChannelManager};
const SUPPORTED_DECODER_KINDS: &[&str] = &["aprs", "ais", "ft8", "ft4", "ft2", "wspr", "hf-aprs"];
use trx_protocol::decoders::resolve_bookmark_decoders;
const CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel";
const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel";
@@ -233,7 +233,7 @@ impl BackgroundDecodeManager {
freq_hz: bookmark.map(|item| item.freq_hz),
mode: bookmark.map(|item| item.mode.clone()),
decoder_kinds: bookmark
.map(bookmark_supported_decoder_kinds)
.map(bookmark_decoder_kinds)
.unwrap_or_default(),
state: "inactive".to_string(),
channel_kind: None,
@@ -383,7 +383,7 @@ impl BackgroundDecodeManager {
continue;
};
let decoder_kinds = bookmark_supported_decoder_kinds(bookmark);
let decoder_kinds = bookmark_decoder_kinds(bookmark);
let mut status = BackgroundDecodeBookmarkStatus {
bookmark_id: bookmark.id.clone(),
bookmark_name: Some(bookmark.name.clone()),
@@ -609,30 +609,8 @@ fn dedup_ids(ids: &[String]) -> Vec<String> {
out
}
fn supported_decoder_kinds(decoders: &[String]) -> Vec<String> {
let mut out = Vec::new();
for decoder in decoders {
let decoder = decoder.trim().to_ascii_lowercase();
if SUPPORTED_DECODER_KINDS.contains(&decoder.as_str())
&& !out.iter().any(|existing| existing == &decoder)
{
out.push(decoder);
}
}
out
}
fn bookmark_supported_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
let explicit = supported_decoder_kinds(&bookmark.decoders);
if !explicit.is_empty() {
return explicit;
}
match bookmark.mode.trim().to_ascii_uppercase().as_str() {
"AIS" => vec!["ais".to_string()],
"PKT" | "FM" => vec!["aprs".to_string()],
_ => Vec::new(),
}
fn bookmark_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
resolve_bookmark_decoders(&bookmark.decoders, &bookmark.mode, true)
}
fn channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool {
@@ -966,8 +966,7 @@ async fn apply_scheduler_decoders(
bookmark: &crate::server::bookmarks::Bookmark,
extra_bookmarks: &[crate::server::bookmarks::Bookmark],
) {
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT")
|| bookmark.mode.trim().eq_ignore_ascii_case("FM");
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
let mut want_hf_aprs = false;
let mut want_ft8 = false;
let mut want_ft4 = false;
@@ -723,7 +723,7 @@ mod tests {
mgr.init_rig(rig_id, 14_074_000, "USB");
let channel = mgr
.allocate(session_id, rig_id, 14_075_000, "USB")
.allocate(session_id, rig_id, 14_075_000, "DIG")
.expect("allocate vchan");
assert_eq!(mgr.channels(rig_id).len(), 2);
@@ -747,7 +747,7 @@ mod tests {
&[(
"bm-ft8".to_string(),
14_074_000,
"USB".to_string(),
"DIG".to_string(),
3_000,
vec!["ft8".to_string()],
)],
@@ -756,7 +756,7 @@ mod tests {
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 2);
assert_eq!(channels[1].freq_hz, 14_074_000);
assert_eq!(channels[1].mode, "USB");
assert_eq!(channels[1].mode, "DIG");
assert_eq!(channels[1].bandwidth_hz, 3_000);
assert_eq!(channels[1].subscribers, 0);
assert!(channels[1].permanent);
@@ -770,14 +770,14 @@ mod tests {
mgr.init_rig(rig_id, 14_074_000, "USB");
let _channel = mgr
.allocate(session_id, rig_id, 14_075_000, "USB")
.allocate(session_id, rig_id, 14_075_000, "DIG")
.expect("allocate vchan");
mgr.sync_scheduler_channels(
rig_id,
&[(
"bm-ft8".to_string(),
14_074_000,
"USB".to_string(),
"DIG".to_string(),
3_000,
vec!["ft8".to_string()],
)],
@@ -787,7 +787,7 @@ mod tests {
let channels = mgr.channels(rig_id);
assert_eq!(channels.len(), 2);
assert_eq!(channels[1].mode, "USB");
assert_eq!(channels[1].mode, "DIG");
assert_eq!(channels[1].subscribers, 0);
}
@@ -803,7 +803,7 @@ mod tests {
&[(
"bm-aprs".to_string(),
144_800_000,
"FM".to_string(),
"PKT".to_string(),
12_500,
vec!["aprs".to_string()],
)],
+250
View File
@@ -0,0 +1,250 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Centralised decoder registry.
//!
//! Every decoder supported by trx-rs is described exactly once here.
//! Backend, frontend, scheduler, and background-decode code all derive
//! their decoder knowledge from [`DECODER_REGISTRY`].
use serde::Serialize;
// ============================================================================
// Types
// ============================================================================
/// How a decoder is activated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DecoderActivation {
/// Automatically active when the rig mode matches.
ModeBound,
/// User-controlled toggle; only runs in `active_modes`.
Toggle,
}
/// Static descriptor for a single decoder.
#[derive(Debug, Clone, Serialize)]
pub struct DecoderDescriptor {
/// Machine identifier, e.g. `"ft8"`, `"aprs"`.
pub id: &'static str,
/// Human-readable label, e.g. `"FT8"`, `"APRS"`.
pub label: &'static str,
/// How the decoder is activated.
pub activation: DecoderActivation,
/// Rig modes where this decoder operates (upper-case).
pub active_modes: &'static [&'static str],
/// Whether the decoder can run on SDR virtual channels
/// (background-decode / scheduler).
pub background_decode: bool,
/// Whether this decoder should appear in bookmark forms.
pub bookmark_selectable: bool,
}
// ============================================================================
// Registry
// ============================================================================
pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
// -- Mode-bound decoders (auto-active when mode matches) -----------------
DecoderDescriptor {
id: "ais",
label: "AIS",
activation: DecoderActivation::ModeBound,
active_modes: &["AIS"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "aprs",
label: "APRS",
activation: DecoderActivation::ModeBound,
active_modes: &["PKT"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "vdes",
label: "VDES",
activation: DecoderActivation::ModeBound,
active_modes: &["VDES"],
background_decode: false,
bookmark_selectable: false,
},
DecoderDescriptor {
id: "cw",
label: "CW",
activation: DecoderActivation::ModeBound,
active_modes: &["CW", "CWR"],
background_decode: false,
bookmark_selectable: false,
},
// -- Toggle-gated decoders (user enables/disables) -----------------------
DecoderDescriptor {
id: "ft8",
label: "FT8",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "ft4",
label: "FT4",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "ft2",
label: "FT2",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "wspr",
label: "WSPR",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "hf-aprs",
label: "HF APRS",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: true,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "lrpt",
label: "Meteor LRPT",
activation: DecoderActivation::Toggle,
active_modes: &["DIG", "USB"],
background_decode: false,
bookmark_selectable: true,
},
];
// ============================================================================
// Helpers
// ============================================================================
/// Resolve a bookmark's effective decoder kinds.
///
/// If `explicit_decoders` is non-empty, filters them to known IDs (optionally
/// restricting to background-capable decoders). Otherwise infers decoders
/// from the bookmark `mode` using mode-bound entries in the registry.
pub fn resolve_bookmark_decoders(
explicit_decoders: &[String],
mode: &str,
background_only: bool,
) -> Vec<String> {
let from_explicit: Vec<String> = explicit_decoders
.iter()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| {
DECODER_REGISTRY.iter().any(|d| {
d.id == s.as_str() && (!background_only || d.background_decode)
})
})
.fold(Vec::new(), |mut acc, s| {
if !acc.contains(&s) {
acc.push(s);
}
acc
});
if !from_explicit.is_empty() {
return from_explicit;
}
// Fall back: infer from mode via mode-bound decoders.
let mode_upper = mode.trim().to_ascii_uppercase();
DECODER_REGISTRY
.iter()
.filter(|d| {
d.activation == DecoderActivation::ModeBound
&& (!background_only || d.background_decode)
&& d.active_modes.contains(&mode_upper.as_str())
})
.map(|d| d.id.to_string())
.collect()
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_decoders_filtered() {
let result = resolve_bookmark_decoders(
&["ft8".into(), "bogus".into(), "ft4".into()],
"USB",
false,
);
assert_eq!(result, vec!["ft8", "ft4"]);
}
#[test]
fn explicit_decoders_deduped() {
let result = resolve_bookmark_decoders(
&["ft8".into(), "FT8".into()],
"USB",
false,
);
assert_eq!(result, vec!["ft8"]);
}
#[test]
fn mode_fallback_ais() {
let result = resolve_bookmark_decoders(&[], "AIS", false);
assert_eq!(result, vec!["ais"]);
}
#[test]
fn mode_fallback_pkt() {
let result = resolve_bookmark_decoders(&[], "PKT", false);
assert_eq!(result, vec!["aprs"]);
}
#[test]
fn mode_fallback_unknown() {
let result = resolve_bookmark_decoders(&[], "USB", false);
assert!(result.is_empty());
}
#[test]
fn background_only_filters_lrpt() {
let result = resolve_bookmark_decoders(
&["lrpt".into(), "ft8".into()],
"DIG",
true,
);
assert_eq!(result, vec!["ft8"]);
}
#[test]
fn background_only_mode_fallback_excludes_cw() {
// CW is mode-bound but not background_decode capable.
let result = resolve_bookmark_decoders(&[], "CW", true);
assert!(result.is_empty());
}
#[test]
fn registry_ids_unique() {
let mut seen = std::collections::HashSet::new();
for d in DECODER_REGISTRY {
assert!(seen.insert(d.id), "duplicate decoder id: {}", d.id);
}
}
}
+2
View File
@@ -9,11 +9,13 @@
pub mod auth;
pub mod codec;
pub mod decoders;
pub mod mapping;
pub mod types;
// Re-export commonly used items
pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, RigEntry};
+9 -9
View File
@@ -1183,7 +1183,7 @@ fn run_playback(
}
}
/// Run the APRS decoder task. Only processes PCM when rig mode is FM or PKT.
/// Run the APRS decoder task. Only processes PCM when rig mode is PKT.
pub async fn run_aprs_decoder(
sample_rate: u32,
channels: u16,
@@ -1257,9 +1257,9 @@ async fn run_aprs_decoder_inner(
let mode_match = |state: &RigState| -> bool {
if is_hf {
matches!(state.status.mode, RigMode::USB | RigMode::DIG)
matches!(state.status.mode, RigMode::DIG)
} else {
matches!(state.status.mode, RigMode::FM | RigMode::PKT)
matches!(state.status.mode, RigMode::PKT)
}
};
let get_reset_seq = |state: &RigState| -> u64 {
@@ -1405,14 +1405,14 @@ pub async fn run_ais_decoder(
let mut decoder_a = AisDecoder::new(sample_rate);
let mut decoder_b = AisDecoder::new(sample_rate);
let mut was_active = false;
let mut active = matches!(state_rx.borrow().status.mode, RigMode::AIS | RigMode::FM | RigMode::PKT);
let mut active = matches!(state_rx.borrow().status.mode, RigMode::AIS);
loop {
if !active {
match state_rx.changed().await {
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::AIS | RigMode::FM | RigMode::PKT);
active = matches!(state.status.mode, RigMode::AIS);
if active {
pcm_a_rx = pcm_a_rx.resubscribe();
pcm_b_rx = pcm_b_rx.resubscribe();
@@ -1476,7 +1476,7 @@ pub async fn run_ais_decoder(
match changed {
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::AIS | RigMode::FM | RigMode::PKT);
active = matches!(state.status.mode, RigMode::AIS);
if !active && was_active {
decoder_a.reset();
decoder_b.reset();
@@ -1505,14 +1505,14 @@ pub async fn run_vdes_decoder(
info!("VDES decoder started ({}Hz complex baseband)", sample_rate);
let mut decoder = VdesDecoder::new(sample_rate);
let mut was_active = false;
let mut active = matches!(state_rx.borrow().status.mode, RigMode::VDES | RigMode::FM);
let mut active = matches!(state_rx.borrow().status.mode, RigMode::VDES);
loop {
if !active {
match state_rx.changed().await {
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::VDES | RigMode::FM);
active = matches!(state.status.mode, RigMode::VDES);
if active {
iq_rx = iq_rx.resubscribe();
}
@@ -1550,7 +1550,7 @@ pub async fn run_vdes_decoder(
match changed {
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::VDES | RigMode::FM);
active = matches!(state.status.mode, RigMode::VDES);
if !active && was_active {
decoder.reset();
was_active = false;
@@ -158,8 +158,7 @@ impl SoapySdrRig {
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
match mode {
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::AIS => 25_000,
RigMode::PKT => 25_000,
RigMode::PKT | RigMode::AIS => 25_000,
RigMode::VDES => 100_000,
RigMode::CW | RigMode::CWR => 500,
RigMode::AM | RigMode::SAM => 9_000,
@@ -318,6 +317,8 @@ impl SoapySdrRig {
RigMode::FM,
RigMode::AIS,
RigMode::VDES,
RigMode::DIG,
RigMode::PKT,
],
num_vfos: 1,
lock: false,
@@ -504,7 +504,7 @@ mod tests {
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
let hidden_id = Uuid::new_v4();
mgr.ensure_background_channel_pcm(hidden_id, 14_075_000, &RigMode::USB)
mgr.ensure_background_channel_pcm(hidden_id, 14_075_000, &RigMode::DIG)
.unwrap();
let visible = mgr.channels();