[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:
@@ -0,0 +1,365 @@
|
|||||||
|
# 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`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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.
|
||||||
@@ -3241,22 +3241,22 @@ function render(update) {
|
|||||||
const wsprStatus = document.getElementById("wspr-status");
|
const wsprStatus = document.getElementById("wspr-status");
|
||||||
setModeBoundDecodeStatus(
|
setModeBoundDecodeStatus(
|
||||||
aisStatus,
|
aisStatus,
|
||||||
["AIS", "FM", "PKT"],
|
["AIS"],
|
||||||
"Select AIS or FM mode to decode",
|
"Select AIS mode to decode",
|
||||||
"Connected, listening for packets",
|
"Connected, listening for packets",
|
||||||
);
|
);
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
if (window.updateAisBar) window.updateAisBar();
|
||||||
setModeBoundDecodeStatus(
|
setModeBoundDecodeStatus(
|
||||||
vdesStatus,
|
vdesStatus,
|
||||||
["VDES", "FM"],
|
["VDES"],
|
||||||
"Select VDES or FM mode to decode",
|
"Select VDES mode to decode",
|
||||||
"Connected, listening for bursts",
|
"Connected, listening for bursts",
|
||||||
);
|
);
|
||||||
if (window.updateVdesBar) window.updateVdesBar();
|
if (window.updateVdesBar) window.updateVdesBar();
|
||||||
setModeBoundDecodeStatus(
|
setModeBoundDecodeStatus(
|
||||||
aprsStatus,
|
aprsStatus,
|
||||||
["PKT", "FM"],
|
["PKT"],
|
||||||
"Select FM mode to decode",
|
"Select PKT mode to decode",
|
||||||
"Connected, listening for packets",
|
"Connected, listening for packets",
|
||||||
);
|
);
|
||||||
if (window.updateAprsBar) window.updateAprsBar();
|
if (window.updateAprsBar) window.updateAprsBar();
|
||||||
@@ -8903,10 +8903,10 @@ function updateDecodeStatus(text) {
|
|||||||
const ft8 = document.getElementById("ft8-status");
|
const ft8 = document.getElementById("ft8-status");
|
||||||
const ft4 = document.getElementById("ft4-status");
|
const ft4 = document.getElementById("ft4-status");
|
||||||
const ft2 = document.getElementById("ft2-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;
|
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(vdes, ["VDES"], "Select VDES mode to decode", vdesText);
|
||||||
setModeBoundDecodeStatus(aprs, ["PKT", "FM"], "Select FM mode to decode", text);
|
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
|
||||||
const cwText = text === "Connected, listening for packets" ? "Connected, listening for CW" : text;
|
const cwText = text === "Connected, listening for packets" ? "Connected, listening for CW" : text;
|
||||||
setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText);
|
setModeBoundDecodeStatus(cw, ["CW", "CWR"], "Select CW mode to decode", cwText);
|
||||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
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" />
|
<input type="number" id="bm-freq" class="status-input" required min="0" placeholder="e.g. 7074000" />
|
||||||
</label>
|
</label>
|
||||||
<label class="bm-label">Mode
|
<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">
|
<datalist id="bm-mode-list">
|
||||||
<option value="LSB">
|
<option value="LSB">
|
||||||
<option value="USB">
|
<option value="USB">
|
||||||
<option value="AM">
|
<option value="AM">
|
||||||
<option value="SAM">
|
<option value="SAM">
|
||||||
<option value="FM">
|
<option value="FM">
|
||||||
|
<option value="DIG">
|
||||||
<option value="CW">
|
<option value="CW">
|
||||||
<option value="WFM">
|
<option value="WFM">
|
||||||
</datalist>
|
</datalist>
|
||||||
@@ -560,25 +561,25 @@
|
|||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
<strong>FT8 Decoder</strong>
|
<strong>FT8 Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<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>
|
</div>
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
<strong>FT4 Decoder</strong>
|
<strong>FT4 Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<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>
|
</div>
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
<strong>FT2 Decoder</strong>
|
<strong>FT2 Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<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>
|
</div>
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
<strong>WSPR Decoder</strong>
|
<strong>WSPR Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<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>
|
</div>
|
||||||
<div class="plugin-item">
|
<div class="plugin-item">
|
||||||
|
|||||||
@@ -322,8 +322,7 @@ function renderAprsHistory() {
|
|||||||
|
|
||||||
function updateAprsBar() {
|
function updateAprsBar() {
|
||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const modeVal = (document.getElementById("mode")?.value || "").toUpperCase();
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
const isPkt = modeVal === "PKT" || modeVal === "FM";
|
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@
|
|||||||
if (supported.length > 0) return supported;
|
if (supported.length > 0) return supported;
|
||||||
const mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
const mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
||||||
if (mode === "AIS") return ["ais"];
|
if (mode === "AIS") return ["ais"];
|
||||||
if (mode === "PKT" || mode === "FM") return ["aprs"];
|
if (mode === "PKT") return ["aprs"];
|
||||||
return supported;
|
return supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -428,9 +428,9 @@ async function bmApply(bm) {
|
|||||||
await postPath("/set_freq?hz=" + bm.freq_hz);
|
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 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 decoderPromise = (hasDecoders && decoderMode) ? (async () => {
|
||||||
const statusResp = await fetch("/status");
|
const statusResp = await fetch("/status");
|
||||||
if (statusResp.ok) {
|
if (statusResp.ok) {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ use trx_frontend::FrontendRuntimeContext;
|
|||||||
|
|
||||||
use super::{gzip_bytes, send_command, RemoteQuery};
|
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
|
// Decode history types and helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -562,6 +562,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(sse::events)
|
.service(sse::events)
|
||||||
.service(sse::spectrum)
|
.service(sse::spectrum)
|
||||||
// Decoder endpoints
|
// Decoder endpoints
|
||||||
|
.service(decoder::decoder_registry)
|
||||||
.service(decoder::decode_history)
|
.service(decoder::decode_history)
|
||||||
.service(decoder::decode_events)
|
.service(decoder::decode_events)
|
||||||
.service(decoder::toggle_aprs_decode)
|
.service(decoder::toggle_aprs_decode)
|
||||||
|
|||||||
@@ -135,30 +135,11 @@ fn sync_scheduler_vchannels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
|
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
trx_protocol::decoders::resolve_bookmark_decoders(
|
||||||
for decoder in bookmark
|
&bookmark.decoders,
|
||||||
.decoders
|
&bookmark.mode,
|
||||||
.iter()
|
true,
|
||||||
.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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use crate::server::bookmarks::{Bookmark, BookmarkStoreMap};
|
|||||||
use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager};
|
use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager};
|
||||||
use crate::server::vchan::{ClientChannel, ClientChannelManager};
|
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 CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel";
|
||||||
const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel";
|
const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel";
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ impl BackgroundDecodeManager {
|
|||||||
freq_hz: bookmark.map(|item| item.freq_hz),
|
freq_hz: bookmark.map(|item| item.freq_hz),
|
||||||
mode: bookmark.map(|item| item.mode.clone()),
|
mode: bookmark.map(|item| item.mode.clone()),
|
||||||
decoder_kinds: bookmark
|
decoder_kinds: bookmark
|
||||||
.map(bookmark_supported_decoder_kinds)
|
.map(bookmark_decoder_kinds)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
state: "inactive".to_string(),
|
state: "inactive".to_string(),
|
||||||
channel_kind: None,
|
channel_kind: None,
|
||||||
@@ -383,7 +383,7 @@ impl BackgroundDecodeManager {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let decoder_kinds = bookmark_supported_decoder_kinds(bookmark);
|
let decoder_kinds = bookmark_decoder_kinds(bookmark);
|
||||||
let mut status = BackgroundDecodeBookmarkStatus {
|
let mut status = BackgroundDecodeBookmarkStatus {
|
||||||
bookmark_id: bookmark.id.clone(),
|
bookmark_id: bookmark.id.clone(),
|
||||||
bookmark_name: Some(bookmark.name.clone()),
|
bookmark_name: Some(bookmark.name.clone()),
|
||||||
@@ -609,30 +609,8 @@ fn dedup_ids(ids: &[String]) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supported_decoder_kinds(decoders: &[String]) -> Vec<String> {
|
fn bookmark_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
resolve_bookmark_decoders(&bookmark.decoders, &bookmark.mode, true)
|
||||||
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 channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool {
|
fn channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool {
|
||||||
|
|||||||
@@ -966,8 +966,7 @@ async fn apply_scheduler_decoders(
|
|||||||
bookmark: &crate::server::bookmarks::Bookmark,
|
bookmark: &crate::server::bookmarks::Bookmark,
|
||||||
extra_bookmarks: &[crate::server::bookmarks::Bookmark],
|
extra_bookmarks: &[crate::server::bookmarks::Bookmark],
|
||||||
) {
|
) {
|
||||||
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT")
|
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
|
||||||
|| bookmark.mode.trim().eq_ignore_ascii_case("FM");
|
|
||||||
let mut want_hf_aprs = false;
|
let mut want_hf_aprs = false;
|
||||||
let mut want_ft8 = false;
|
let mut want_ft8 = false;
|
||||||
let mut want_ft4 = false;
|
let mut want_ft4 = false;
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ mod tests {
|
|||||||
|
|
||||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||||
let channel = mgr
|
let channel = mgr
|
||||||
.allocate(session_id, rig_id, 14_075_000, "USB")
|
.allocate(session_id, rig_id, 14_075_000, "DIG")
|
||||||
.expect("allocate vchan");
|
.expect("allocate vchan");
|
||||||
|
|
||||||
assert_eq!(mgr.channels(rig_id).len(), 2);
|
assert_eq!(mgr.channels(rig_id).len(), 2);
|
||||||
@@ -747,7 +747,7 @@ mod tests {
|
|||||||
&[(
|
&[(
|
||||||
"bm-ft8".to_string(),
|
"bm-ft8".to_string(),
|
||||||
14_074_000,
|
14_074_000,
|
||||||
"USB".to_string(),
|
"DIG".to_string(),
|
||||||
3_000,
|
3_000,
|
||||||
vec!["ft8".to_string()],
|
vec!["ft8".to_string()],
|
||||||
)],
|
)],
|
||||||
@@ -756,7 +756,7 @@ mod tests {
|
|||||||
let channels = mgr.channels(rig_id);
|
let channels = mgr.channels(rig_id);
|
||||||
assert_eq!(channels.len(), 2);
|
assert_eq!(channels.len(), 2);
|
||||||
assert_eq!(channels[1].freq_hz, 14_074_000);
|
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].bandwidth_hz, 3_000);
|
||||||
assert_eq!(channels[1].subscribers, 0);
|
assert_eq!(channels[1].subscribers, 0);
|
||||||
assert!(channels[1].permanent);
|
assert!(channels[1].permanent);
|
||||||
@@ -770,14 +770,14 @@ mod tests {
|
|||||||
|
|
||||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||||
let _channel = mgr
|
let _channel = mgr
|
||||||
.allocate(session_id, rig_id, 14_075_000, "USB")
|
.allocate(session_id, rig_id, 14_075_000, "DIG")
|
||||||
.expect("allocate vchan");
|
.expect("allocate vchan");
|
||||||
mgr.sync_scheduler_channels(
|
mgr.sync_scheduler_channels(
|
||||||
rig_id,
|
rig_id,
|
||||||
&[(
|
&[(
|
||||||
"bm-ft8".to_string(),
|
"bm-ft8".to_string(),
|
||||||
14_074_000,
|
14_074_000,
|
||||||
"USB".to_string(),
|
"DIG".to_string(),
|
||||||
3_000,
|
3_000,
|
||||||
vec!["ft8".to_string()],
|
vec!["ft8".to_string()],
|
||||||
)],
|
)],
|
||||||
@@ -787,7 +787,7 @@ mod tests {
|
|||||||
|
|
||||||
let channels = mgr.channels(rig_id);
|
let channels = mgr.channels(rig_id);
|
||||||
assert_eq!(channels.len(), 2);
|
assert_eq!(channels.len(), 2);
|
||||||
assert_eq!(channels[1].mode, "USB");
|
assert_eq!(channels[1].mode, "DIG");
|
||||||
assert_eq!(channels[1].subscribers, 0);
|
assert_eq!(channels[1].subscribers, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +803,7 @@ mod tests {
|
|||||||
&[(
|
&[(
|
||||||
"bm-aprs".to_string(),
|
"bm-aprs".to_string(),
|
||||||
144_800_000,
|
144_800_000,
|
||||||
"FM".to_string(),
|
"PKT".to_string(),
|
||||||
12_500,
|
12_500,
|
||||||
vec!["aprs".to_string()],
|
vec!["aprs".to_string()],
|
||||||
)],
|
)],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
|
pub mod decoders;
|
||||||
pub mod mapping;
|
pub mod mapping;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
// Re-export commonly used items
|
// Re-export commonly used items
|
||||||
pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
|
pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
|
||||||
pub use codec::{mode_to_string, parse_envelope, parse_mode};
|
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 mapping::{client_command_to_rig, rig_command_to_client};
|
||||||
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, RigEntry};
|
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, RigEntry};
|
||||||
|
|||||||
@@ -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(
|
pub async fn run_aprs_decoder(
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u16,
|
channels: u16,
|
||||||
@@ -1257,9 +1257,9 @@ async fn run_aprs_decoder_inner(
|
|||||||
|
|
||||||
let mode_match = |state: &RigState| -> bool {
|
let mode_match = |state: &RigState| -> bool {
|
||||||
if is_hf {
|
if is_hf {
|
||||||
matches!(state.status.mode, RigMode::USB | RigMode::DIG)
|
matches!(state.status.mode, RigMode::DIG)
|
||||||
} else {
|
} else {
|
||||||
matches!(state.status.mode, RigMode::FM | RigMode::PKT)
|
matches!(state.status.mode, RigMode::PKT)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let get_reset_seq = |state: &RigState| -> u64 {
|
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_a = AisDecoder::new(sample_rate);
|
||||||
let mut decoder_b = AisDecoder::new(sample_rate);
|
let mut decoder_b = AisDecoder::new(sample_rate);
|
||||||
let mut was_active = false;
|
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 {
|
loop {
|
||||||
if !active {
|
if !active {
|
||||||
match state_rx.changed().await {
|
match state_rx.changed().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let state = state_rx.borrow();
|
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 {
|
if active {
|
||||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
pcm_a_rx = pcm_a_rx.resubscribe();
|
||||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
pcm_b_rx = pcm_b_rx.resubscribe();
|
||||||
@@ -1476,7 +1476,7 @@ pub async fn run_ais_decoder(
|
|||||||
match changed {
|
match changed {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let state = state_rx.borrow();
|
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 {
|
if !active && was_active {
|
||||||
decoder_a.reset();
|
decoder_a.reset();
|
||||||
decoder_b.reset();
|
decoder_b.reset();
|
||||||
@@ -1505,14 +1505,14 @@ pub async fn run_vdes_decoder(
|
|||||||
info!("VDES decoder started ({}Hz complex baseband)", sample_rate);
|
info!("VDES decoder started ({}Hz complex baseband)", sample_rate);
|
||||||
let mut decoder = VdesDecoder::new(sample_rate);
|
let mut decoder = VdesDecoder::new(sample_rate);
|
||||||
let mut was_active = false;
|
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 {
|
loop {
|
||||||
if !active {
|
if !active {
|
||||||
match state_rx.changed().await {
|
match state_rx.changed().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
active = matches!(state.status.mode, RigMode::VDES | RigMode::FM);
|
active = matches!(state.status.mode, RigMode::VDES);
|
||||||
if active {
|
if active {
|
||||||
iq_rx = iq_rx.resubscribe();
|
iq_rx = iq_rx.resubscribe();
|
||||||
}
|
}
|
||||||
@@ -1550,7 +1550,7 @@ pub async fn run_vdes_decoder(
|
|||||||
match changed {
|
match changed {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let state = state_rx.borrow();
|
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 {
|
if !active && was_active {
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
was_active = false;
|
was_active = false;
|
||||||
|
|||||||
@@ -158,8 +158,7 @@ impl SoapySdrRig {
|
|||||||
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||||
match mode {
|
match mode {
|
||||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||||
RigMode::AIS => 25_000,
|
RigMode::PKT | RigMode::AIS => 25_000,
|
||||||
RigMode::PKT => 25_000,
|
|
||||||
RigMode::VDES => 100_000,
|
RigMode::VDES => 100_000,
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
RigMode::AM | RigMode::SAM => 9_000,
|
RigMode::AM | RigMode::SAM => 9_000,
|
||||||
@@ -318,6 +317,8 @@ impl SoapySdrRig {
|
|||||||
RigMode::FM,
|
RigMode::FM,
|
||||||
RigMode::AIS,
|
RigMode::AIS,
|
||||||
RigMode::VDES,
|
RigMode::VDES,
|
||||||
|
RigMode::DIG,
|
||||||
|
RigMode::PKT,
|
||||||
],
|
],
|
||||||
num_vfos: 1,
|
num_vfos: 1,
|
||||||
lock: false,
|
lock: false,
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ mod tests {
|
|||||||
|
|
||||||
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
let hidden_id = Uuid::new_v4();
|
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();
|
.unwrap();
|
||||||
|
|
||||||
let visible = mgr.channels();
|
let visible = mgr.channels();
|
||||||
|
|||||||
Reference in New Issue
Block a user