[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:
File diff suppressed because one or more lines are too long
Submodule
+1
Submodule .claude/worktrees/agent-a0253fe3 added at 060454780f
Submodule
+1
Submodule .claude/worktrees/agent-a119dfd0 added at 0008d62c87
Submodule
+1
Submodule .claude/worktrees/agent-a13360c6 added at 060454780f
Submodule
+1
Submodule .claude/worktrees/agent-a3ef51e0 added at 5666533bdb
Submodule
+1
Submodule .claude/worktrees/agent-a523ee45 added at bdd9a48207
Submodule
+1
Submodule .claude/worktrees/agent-a7709b41 added at 80afb928ae
Submodule
+1
Submodule .claude/worktrees/agent-a86b2c7d added at 060454780f
Submodule
+1
Submodule .claude/worktrees/agent-aac0b592 added at 060454780f
Submodule
+1
Submodule .claude/worktrees/agent-ab8ff016 added at 060454780f
Submodule
+1
Submodule .claude/worktrees/agent-ac624bc4 added at b9005acffd
Submodule
+1
Submodule .claude/worktrees/agent-ac96f835 added at 060454780f
Submodule
+1
Submodule .claude/worktrees/agent-ae42db96 added at 80afb928ae
Submodule
+1
Submodule .claude/worktrees/agent-aebe7ef5 added at 060454780f
+9
-9
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user