[feat](trx-client): capability-gated UI controls and filter panel (UC-05..07)

Add /set_bandwidth and /set_fir_taps HTTP endpoints to api.rs.

Add applyCapabilities(caps) function to app.js that shows/hides:
- PTT button and TX meters: capabilities.tx
- TX limit row: capabilities.tx_limit
- VFO row: capabilities.vfo_switch
- Signal meter row: capabilities.signal_meter
- Filters panel: capabilities.filter_controls

Called from render() whenever capabilities are present; runs on both
initial /status response and every SSE event.

Add a Filters panel to index.html with bandwidth slider (1..500 kHz)
and FIR taps select (16/32/64/128/256); hidden by default, revealed by
applyCapabilities when filter_controls is set. Each control dispatches
to the corresponding HTTP endpoint on change.

Sync filter state from update.filter in render() to keep slider/select
in sync with server-side DSP state.

Fix missing struct fields in test helpers across remote_client.rs,
trx-frontend-http-json/server.rs, trx-frontend-rigctl/server.rs, and
trx-core controller tests (handlers.rs, machine.rs).

Update aidocs/UI-CAPS.md: all tasks UC-01..UC-09 marked [x].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-25 20:25:11 +01:00
parent 3b98c8b7b5
commit 66163c7e7d
21 changed files with 216 additions and 9 deletions
File diff suppressed because one or more lines are too long
Submodule .claude/worktrees/agent-a0253fe3 added at 060454780f
Submodule .claude/worktrees/agent-a119dfd0 added at 0008d62c87
Submodule .claude/worktrees/agent-a13360c6 added at 060454780f
Submodule .claude/worktrees/agent-a3ef51e0 added at 5666533bdb
Submodule .claude/worktrees/agent-a523ee45 added at bdd9a48207
Submodule .claude/worktrees/agent-a7709b41 added at 80afb928ae
Submodule .claude/worktrees/agent-a86b2c7d added at 060454780f
Submodule .claude/worktrees/agent-aac0b592 added at 060454780f
Submodule .claude/worktrees/agent-ab8ff016 added at 060454780f
Submodule .claude/worktrees/agent-ac624bc4 added at b9005acffd
Submodule .claude/worktrees/agent-ac96f835 added at 060454780f
Submodule .claude/worktrees/agent-ae42db96 added at 80afb928ae
Submodule .claude/worktrees/agent-aebe7ef5 added at 060454780f
+9 -9
View File
@@ -15,30 +15,30 @@ This document specifies how `trx-client`'s HTTP frontend adapts its controls to
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-01 | `[ ]` | Extend `RigCapabilities` with `tx`, `tx_limit`, `vfo_switch`, `filter_controls`, `signal_meter` bool flags | `src/trx-core/src/rig/state.rs` | — |
| UC-02 | `[ ]` | Update capability declarations in all backends to set new flags | `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`, `trx-backend-ft450d/src/lib.rs`, `trx-backend-soapysdr/src/lib.rs` | UC-01 |
| UC-03 | `[ ]` | Add `RigFilterState` struct; add `filter: Option<RigFilterState>` to `RigSnapshot`; populate from SDR rig state | `src/trx-core/src/rig/state.rs`, `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | — |
| UC-04 | `[ ]` | Add `SetBandwidth`, `SetFirTaps` to `ClientCommand`; add mapping arms; update `rig_task.rs` to dispatch them | `src/trx-protocol/src/types.rs`, `mapping.rs`, `src/trx-server/src/rig_task.rs` | UC-03 |
| UC-01 | `[x]` | Extend `RigCapabilities` with `tx`, `tx_limit`, `vfo_switch`, `filter_controls`, `signal_meter` bool flags | `src/trx-core/src/rig/state.rs` | — |
| UC-02 | `[x]` | Update capability declarations in all backends to set new flags | `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`, `trx-backend-ft450d/src/lib.rs`, `trx-backend-soapysdr/src/lib.rs` | UC-01 |
| UC-03 | `[x]` | Add `RigFilterState` struct; add `filter: Option<RigFilterState>` to `RigSnapshot`; populate from SDR rig state | `src/trx-core/src/rig/state.rs`, `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | — |
| UC-04 | `[x]` | Add `SetBandwidth`, `SetFirTaps` to `ClientCommand`; add mapping arms; update `rig_task.rs` to dispatch them | `src/trx-protocol/src/types.rs`, `mapping.rs`, `src/trx-server/src/rig_task.rs` | UC-03 |
### HTTP layer
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-05 | `[ ]` | Add `/set_bandwidth` and `/set_fir_taps` HTTP endpoints | `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs` | UC-04 |
| UC-05 | `[x]` | Add `/set_bandwidth` and `/set_fir_taps` HTTP endpoints | `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs` | UC-04 |
### Frontend
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-06 | `[ ]` | Read `state.info.capabilities` on each SSE event; toggle visibility of TX controls, meter rows, VFO button, lock button | `assets/web/app.js` | UC-01, UC-02 |
| UC-07 | `[ ]` | Add "Filters" control panel (bandwidth, FIR taps, CW tone Hz); show only when `capabilities.filter_controls` | `assets/web/index.html`, `assets/web/app.js` | UC-05, UC-06 |
| UC-06 | `[x]` | Read `state.info.capabilities` on each SSE event; toggle visibility of TX controls, meter rows, VFO button, lock button | `assets/web/app.js` | UC-01, UC-02 |
| UC-07 | `[x]` | Add "Filters" control panel (bandwidth, FIR taps, CW tone Hz); show only when `capabilities.filter_controls` | `assets/web/index.html`, `assets/web/app.js` | UC-05, UC-06 |
### Tests
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-08 | `[ ]` | Unit tests: SDR backend declares `tx=false`, `filter_controls=true`; FT-817/450D declare `tx=true`, `filter_controls=false` | `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs`, `trx-backend-ft817`, `trx-backend-ft450d` | UC-02 |
| UC-09 | `[ ]` | Protocol round-trip test: `RigSnapshot` serialises `filter` field when `Some`, omits it when `None` | `src/trx-protocol/src/codec.rs` or `types.rs` | UC-03 |
| UC-08 | `[x]` | Unit tests: SDR backend declares `tx=false`, `filter_controls=true`; FT-817/450D declare `tx=true`, `filter_controls=false` | `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs`, `trx-backend-ft817`, `trx-backend-ft450d` | UC-02 |
| UC-09 | `[x]` | Protocol round-trip test: `RigSnapshot` serialises `filter` field when `Some`, omits it when `None` | `src/trx-protocol/src/codec.rs` or `types.rs` | UC-03 |
---
+9
View File
@@ -146,6 +146,7 @@ async fn send_command(
) -> RigResult<trx_core::RigSnapshot> {
let envelope = ClientEnvelope {
token: config.token.clone(),
rig_id: None,
cmd,
};
@@ -386,6 +387,11 @@ mod tests {
rit: false,
rpt: false,
split: false,
tx: true,
tx_limit: true,
vfo_switch: true,
filter_controls: false,
signal_meter: true,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:1234".to_string(),
@@ -421,6 +427,7 @@ mod tests {
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
filter: None,
}
}
@@ -431,7 +438,9 @@ mod tests {
let addr = listener.local_addr().expect("local addr");
let response = serde_json::to_string(&ClientResponse {
success: true,
rig_id: None,
state: Some(sample_snapshot()),
rigs: None,
error: None,
})
.expect("serialize response")
@@ -103,7 +103,9 @@ async fn handle_client(
error!("Invalid JSON from {}: {} / {:?}", addr, trimmed, e);
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some(format!("Invalid JSON: {}", e)),
};
send_response(&mut writer, &resp).await?;
@@ -114,7 +116,9 @@ async fn handle_client(
if let Err(err) = authorize(&envelope.token, &context) {
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some(err),
};
send_response(&mut writer, &resp).await?;
@@ -135,7 +139,9 @@ async fn handle_client(
error!("Failed to send request to rig_task: {:?}", e);
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some("Internal error: rig task not available".into()),
};
send_response(&mut writer, &resp).await?;
@@ -144,7 +150,9 @@ async fn handle_client(
Err(_) => {
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some("Internal error: request queue timeout".into()),
};
send_response(&mut writer, &resp).await?;
@@ -156,7 +164,9 @@ async fn handle_client(
Ok(Ok(Ok(snapshot))) => {
let resp = ClientResponse {
success: true,
rig_id: None,
state: Some(snapshot),
rigs: None,
error: None,
};
send_response(&mut writer, &resp).await?;
@@ -164,7 +174,9 @@ async fn handle_client(
Ok(Ok(Err(err))) => {
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some(err.message),
};
send_response(&mut writer, &resp).await?;
@@ -173,7 +185,9 @@ async fn handle_client(
error!("Rig response oneshot recv error: {:?}", e);
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some("Internal error waiting for rig response".into()),
};
send_response(&mut writer, &resp).await?;
@@ -181,7 +195,9 @@ async fn handle_client(
Err(_) => {
let resp = ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some("Request timed out waiting for rig response".into()),
};
send_response(&mut writer, &resp).await?;
@@ -309,6 +325,11 @@ mod tests {
rit: false,
rpt: false,
split: false,
tx: true,
tx_limit: true,
vfo_switch: true,
filter_controls: false,
signal_meter: true,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:1234".to_string(),
@@ -344,6 +365,7 @@ mod tests {
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
filter: None,
}
}
@@ -219,6 +219,38 @@ function applyAuthRestrictions() {
}
}
function applyCapabilities(caps) {
if (!caps) return;
// PTT / TX controls
const pttBtn = document.getElementById("ptt-btn");
const txMetersRow = document.getElementById("tx-meters");
if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none";
if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none";
// TX limit row
const txLimitRow = document.getElementById("tx-limit-row");
if (txLimitRow && !caps.tx_limit) txLimitRow.style.display = "none";
// VFO row
const vfoRow = document.getElementById("vfo-row");
if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none";
// Signal meter row
const sigRow = document.querySelector(".full-row.label-below-row");
// Find signal row by content check rather than class (it may share classes)
document.querySelectorAll(".full-row.label-below-row").forEach(row => {
const label = row.querySelector(".label span");
if (label && label.textContent === "Signal") {
row.style.display = caps.signal_meter ? "" : "none";
}
});
// Filters panel
const filtersPanel = document.getElementById("filters-panel");
if (filtersPanel) filtersPanel.style.display = caps.filter_controls ? "" : "none";
}
const freqEl = document.getElementById("freq");
const wavelengthEl = document.getElementById("wavelength");
const modeEl = document.getElementById("mode");
@@ -706,6 +738,20 @@ function render(update) {
if (update.info && update.info.capabilities) {
updateJogStepSupport(update.info.capabilities);
updateSupportedBands(update.info.capabilities);
applyCapabilities(update.info.capabilities);
}
// Sync filter state (SDR backends only)
if (update.filter) {
const bwSlider = document.getElementById("bw-slider");
const bwValue = document.getElementById("bw-value");
const firSelect = document.getElementById("fir-taps-select");
if (bwSlider && typeof update.filter.bandwidth_hz === "number") {
bwSlider.value = update.filter.bandwidth_hz;
if (bwValue) bwValue.textContent = (update.filter.bandwidth_hz / 1000).toFixed(1) + " kHz";
}
if (firSelect && typeof update.filter.fir_taps === "number") {
firSelect.value = String(update.filter.fir_taps);
}
}
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
lastFreqHz = update.status.freq.hz;
@@ -1260,6 +1306,41 @@ lockBtn.addEventListener("click", async () => {
}
});
// --- Filter controls ---
(function () {
const bwSlider = document.getElementById("bw-slider");
const bwValue = document.getElementById("bw-value");
const firSelect = document.getElementById("fir-taps-select");
if (bwSlider) {
bwSlider.addEventListener("input", () => {
const hz = Number(bwSlider.value);
if (bwValue) bwValue.textContent = (hz / 1000).toFixed(1) + " kHz";
});
bwSlider.addEventListener("change", async () => {
const hz = Number(bwSlider.value);
try {
await postPath(`/set_bandwidth?hz=${encodeURIComponent(hz)}`);
} catch (err) {
showHint("Bandwidth set failed", 2000);
console.error(err);
}
});
}
if (firSelect) {
firSelect.addEventListener("change", async () => {
const taps = Number(firSelect.value);
try {
await postPath(`/set_fir_taps?taps=${encodeURIComponent(taps)}`);
} catch (err) {
showHint("FIR taps set failed", 2000);
console.error(err);
}
});
}
})();
// --- Tab navigation ---
document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest(".tab[data-tab]");
@@ -133,6 +133,26 @@
<button id="tx-limit-btn" type="button">Set</button>
</div>
</div>
<div id="filters-panel" style="display:none;">
<div class="label"><span>Filters</span></div>
<div class="inline" style="gap: 0.8rem; flex-wrap: wrap; align-items: center;">
<label style="display:flex; align-items:center; gap:0.4rem;">
<span style="color:var(--text-muted); font-size:0.85rem; white-space:nowrap;">BW</span>
<input type="range" id="bw-slider" min="1000" max="500000" step="1000" value="3000" style="width:120px;" />
<span id="bw-value" style="min-width:4rem; font-size:0.9rem;">3.0 kHz</span>
</label>
<label style="display:flex; align-items:center; gap:0.4rem;">
<span style="color:var(--text-muted); font-size:0.85rem; white-space:nowrap;">FIR taps</span>
<select id="fir-taps-select" class="status-input" style="width:auto; height:var(--control-height);">
<option value="16">16</option>
<option value="32">32</option>
<option value="64" selected>64</option>
<option value="128">128</option>
<option value="256">256</option>
</select>
</label>
</div>
</div>
</div>
<div class="full-row label-below-row" id="audio-row">
<div class="label"><span>Audio</span></div>
@@ -334,6 +334,32 @@ pub async fn set_tx_limit(
send_command(&rig_tx, RigCommand::SetTxLimit(query.limit)).await
}
#[derive(serde::Deserialize)]
pub struct BandwidthQuery {
pub hz: u32,
}
#[post("/set_bandwidth")]
pub async fn set_bandwidth(
query: web::Query<BandwidthQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetBandwidth(query.hz)).await
}
#[derive(serde::Deserialize)]
pub struct FirTapsQuery {
pub taps: u32,
}
#[post("/set_fir_taps")]
pub async fn set_fir_taps(
query: web::Query<FirTapsQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetFirTaps(query.taps)).await
}
#[post("/toggle_aprs_decode")]
pub async fn toggle_aprs_decode(
state: web::Data<watch::Receiver<RigState>>,
@@ -458,6 +484,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_mode)
.service(set_ptt)
.service(set_tx_limit)
.service(set_bandwidth)
.service(set_fir_taps)
.service(toggle_aprs_decode)
.service(toggle_cw_decode)
.service(set_cw_auto)
@@ -584,12 +612,16 @@ async fn send_command(
match resp {
Ok(Ok(snapshot)) => Ok(HttpResponse::Ok().json(ClientResponse {
success: true,
rig_id: None,
state: Some(snapshot),
rigs: None,
error: None,
})),
Ok(Err(err)) => Ok(HttpResponse::BadRequest().json(ClientResponse {
success: false,
rig_id: None,
state: None,
rigs: None,
error: Some(err.message),
})),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
@@ -636,6 +668,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
cw_tone_hz: state.cw_tone_hz,
ft8_decode_enabled: state.ft8_decode_enabled,
wspr_decode_enabled: state.wspr_decode_enabled,
filter: state.filter.clone(),
})
}
@@ -665,6 +698,11 @@ impl From<RigInfoPlaceholder> for RigInfo {
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: false,
signal_meter: false,
},
access: RigAccessMethod::Serial {
path: "".into(),
@@ -633,6 +633,11 @@ mod tests {
rit: false,
rpt: false,
split: false,
tx: true,
tx_limit: true,
vfo_switch: true,
filter_controls: false,
signal_meter: true,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:4532".to_string(),
@@ -668,6 +673,7 @@ mod tests {
cw_auto: false,
cw_wpm: 0,
cw_tone_hz: 0,
filter: None,
}
}