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>
12 KiB
Decoder Registry Consolidation Plan
Status: Proposed
Target crate: trx-protocol
Problem
The set of supported decoders and their mode/activation semantics is defined independently in 4+ locations with no single source of truth. Adding a new decoder requires touching every copy; the copies are already out of sync.
Duplicate definitions today
| Location | What it defines | Drift |
|---|---|---|
background_decode.rs SUPPORTED_DECODER_KINDS |
["aprs","ais","ft8","ft4","ft2","wspr","hf-aprs"] |
Missing lrpt |
background_decode.rs bookmark_supported_decoder_kinds() |
Mode fallback: AIS→ais, PKT→aprs |
— |
sse.rs bookmark_decoder_kinds() |
Same mode fallback, duplicated | — |
background-decode.js SUPPORTED_DECODERS |
["aprs","ais","ft8","wspr","hf-aprs"] |
Missing ft4, ft2, lrpt |
background-decode.js bookmarkDecoderKinds() |
Mode fallback: AIS→ais, PKT→aprs |
— |
bookmarks.js bmReadDecoders() / bmWriteDecoders() |
8 hardcoded checkbox IDs | — |
app.js _decoderToggles |
6 hardcoded toggle button entries | — |
app.js setModeBoundDecodeStatus() calls |
Hardcoded mode→decoder pairs | — |
app.js bmApply() |
Gates decoder toggles on mode === "DIG" || mode === "FM" |
— |
Implicit categories
Two activation patterns exist but are never formalised:
- Mode-bound — always active when the rig is in the matching mode (AIS, APRS/PKT, CW, VDES, RDS). No user toggle.
- Toggle-gated — user explicitly enables/disables; only meaningful in certain modes (FT8, FT4, FT2, WSPR, HF-APRS, LRPT).
Design
1. Decoder registry in trx-protocol
Add src/trx-protocol/src/decoders.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
}
pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
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,
},
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,
},
];
Re-export from trx-protocol/src/lib.rs:
pub mod decoders;
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
2. Shared resolver functions in trx-protocol::decoders
Replace all duplicated bookmark→decoder resolution with shared helpers:
/// Return decoder IDs supported for background-decode / virtual channels.
pub fn background_decoder_ids() -> impl Iterator<Item = &'static str> {
DECODER_REGISTRY.iter()
.filter(|d| d.background_decode)
.map(|d| d.id)
}
/// Resolve a bookmark's effective decoder kinds.
///
/// If the bookmark has explicit `decoders`, filter to supported IDs.
/// Otherwise, infer from mode using mode-bound entries in the registry.
pub fn resolve_bookmark_decoders(
explicit_decoders: &[String],
mode: &str,
background_only: bool,
) -> Vec<String> {
let supported: Vec<&DecoderDescriptor> = DECODER_REGISTRY.iter()
.filter(|d| !background_only || d.background_decode)
.collect();
// Explicit decoders take priority.
let from_explicit: Vec<String> = explicit_decoders.iter()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| supported.iter().any(|d| d.id == s.as_str()))
.collect();
if !from_explicit.is_empty() {
return dedup(from_explicit);
}
// Fall back: infer from mode via mode-bound decoders.
let mode_upper = mode.trim().to_ascii_uppercase();
supported.iter()
.filter(|d| d.activation == DecoderActivation::ModeBound
&& d.active_modes.contains(&mode_upper.as_str()))
.map(|d| d.id.to_string())
.collect()
}
3. REST endpoint /decoders
Add a GET /decoders handler in trx-frontend-http that serialises
DECODER_REGISTRY as JSON. The frontend fetches this once on page load
and uses it to drive all decoder-related UI.
4. Delete duplicated Rust code
| File | Delete |
|---|---|
background_decode.rs |
SUPPORTED_DECODER_KINDS, supported_decoder_kinds(), bookmark_supported_decoder_kinds() |
sse.rs |
bookmark_decoder_kinds() |
Replace call sites with trx_protocol::decoders::resolve_bookmark_decoders().
5. Refactor JS to consume the registry
bookmarks.js
Replace bmReadDecoders() / bmWriteDecoders() with data-driven functions:
// Populated from GET /decoders on page load.
let decoderRegistry = [];
function bmReadDecoders() {
return decoderRegistry
.filter(d => d.bookmark_selectable)
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
.map(d => d.id);
}
function bmWriteDecoders(list) {
const set = new Set(list || []);
decoderRegistry
.filter(d => d.bookmark_selectable)
.forEach(d => {
const el = document.getElementById("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id);
});
}
Generate the checkbox HTML from the registry instead of hardcoding 8 inputs.
bmApply() in bookmarks.js
Replace the mode === "DIG" || mode === "FM" gate:
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const toggleDecoders = decoderRegistry.filter(d => d.activation === "toggle");
const shouldToggle = hasDecoders && toggleDecoders.some(d =>
d.active_modes.includes(bm.mode.toUpperCase())
);
background-decode.js
Delete SUPPORTED_DECODERS constant and bookmarkDecoderKinds(). Replace
with the shared registry:
function bookmarkDecoderKinds(bookmark) {
// Use the same resolution logic as the backend, driven by the registry.
const explicit = (bookmark.decoders || [])
.map(s => s.trim().toLowerCase())
.filter(s => decoderRegistry.some(d => d.background_decode && d.id === s));
if (explicit.length > 0) return explicit;
const mode = (bookmark.mode || "").toUpperCase();
return decoderRegistry
.filter(d => d.activation === "mode_bound"
&& d.background_decode
&& d.active_modes.includes(mode))
.map(d => d.id);
}
app.js — decoder toggle buttons
Replace the hardcoded _decoderToggles object and syncDecoderToggle() loop.
Generate toggle buttons from decoderRegistry.filter(d => d.activation === "toggle").
app.js — setModeBoundDecodeStatus()
Drive the calls from the registry instead of hardcoded mode arrays:
decoderRegistry
.filter(d => d.activation === "mode_bound")
.forEach(d => {
const el = document.getElementById(d.id + "-status");
setModeBoundDecodeStatus(el, d.active_modes, ...);
});
app.js — FT8/WSPR status gating
Replace modeUpper !== "DIG" && modeUpper !== "USB" with a registry lookup:
const ft8Desc = decoderRegistry.find(d => d.id === "ft8");
if (ft8Desc && !ft8Desc.active_modes.includes(modeUpper)) { ... }
6. Generate bookmark form decoder checkboxes
The HTML currently has 8 hardcoded <label><input id="bm-dec-ft8" ...> blocks.
Replace with a <div id="bm-decoder-checkboxes"></div> container populated on
load from the registry:
function bmBuildDecoderCheckboxes() {
const container = document.getElementById("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
decoderRegistry
.filter(d => d.bookmark_selectable)
.forEach(d => {
const label = document.createElement("label");
label.innerHTML = `<input type="checkbox" id="bm-dec-${d.id}" /> ${d.label}`;
container.appendChild(label);
});
}
Implementation order
graph TD
A["1. Add decoders.rs to trx-protocol"] --> B["2. Add GET /decoders endpoint"]
A --> C["3. Replace Rust call sites"]
C --> C1["3a. background_decode.rs"]
C --> C2["3b. sse.rs"]
B --> D["4. JS: fetch registry on load"]
D --> E["5. JS: bookmarks.js refactor"]
D --> F["6. JS: background-decode.js refactor"]
D --> G["7. JS: app.js toggle/status refactor"]
E --> H["8. HTML: dynamic decoder checkboxes"]
Phase 1 (Rust, no UI change): Steps 1-3. Pure refactor, no user-visible change. All existing tests should continue to pass since behaviour is identical.
Phase 2 (JS, progressive): Steps 4-8. Can be done file-by-file. Each
step is independently testable since the /decoders endpoint is available.
What changes for adding a new decoder
Before: Edit SUPPORTED_DECODER_KINDS in Rust, SUPPORTED_DECODERS in JS,
bmReadDecoders/bmWriteDecoders, the HTML form, _decoderToggles,
syncDecoderToggle calls, and setModeBoundDecodeStatus calls. (~8 files,
~12 edit sites.)
After: Add one entry to DECODER_REGISTRY in trx-protocol/src/decoders.rs.
Everything else derives.