From 66163c7e7d571c752ac06c6657e4cd6ed6112945 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 25 Feb 2026 20:25:11 +0100 Subject: [PATCH] [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 Signed-off-by: Stan Grams --- .claude/settings.local.json | 18 +++++ .claude/worktrees/agent-a0253fe3 | 1 + .claude/worktrees/agent-a119dfd0 | 1 + .claude/worktrees/agent-a13360c6 | 1 + .claude/worktrees/agent-a3ef51e0 | 1 + .claude/worktrees/agent-a523ee45 | 1 + .claude/worktrees/agent-a7709b41 | 1 + .claude/worktrees/agent-a86b2c7d | 1 + .claude/worktrees/agent-aac0b592 | 1 + .claude/worktrees/agent-ab8ff016 | 1 + .claude/worktrees/agent-ac624bc4 | 1 + .claude/worktrees/agent-ac96f835 | 1 + .claude/worktrees/agent-ae42db96 | 1 + .claude/worktrees/agent-aebe7ef5 | 1 + aidocs/UI-CAPS.md | 18 ++--- src/trx-client/src/remote_client.rs | 9 +++ .../trx-frontend-http-json/src/server.rs | 22 +++++ .../trx-frontend-http/assets/web/app.js | 81 +++++++++++++++++++ .../trx-frontend-http/assets/web/index.html | 20 +++++ .../trx-frontend/trx-frontend-http/src/api.rs | 38 +++++++++ .../trx-frontend-rigctl/src/server.rs | 6 ++ 21 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 .claude/settings.local.json create mode 160000 .claude/worktrees/agent-a0253fe3 create mode 160000 .claude/worktrees/agent-a119dfd0 create mode 160000 .claude/worktrees/agent-a13360c6 create mode 160000 .claude/worktrees/agent-a3ef51e0 create mode 160000 .claude/worktrees/agent-a523ee45 create mode 160000 .claude/worktrees/agent-a7709b41 create mode 160000 .claude/worktrees/agent-a86b2c7d create mode 160000 .claude/worktrees/agent-aac0b592 create mode 160000 .claude/worktrees/agent-ab8ff016 create mode 160000 .claude/worktrees/agent-ac624bc4 create mode 160000 .claude/worktrees/agent-ac96f835 create mode 160000 .claude/worktrees/agent-ae42db96 create mode 160000 .claude/worktrees/agent-aebe7ef5 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b3ac656 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(cargo test:*)", + "Bash(cargo build:*)", + "Bash(git:*)", + "Bash(cat:*)", + "Bash(cargo check:*)", + "Bash(python3:*)", + "Bash(ls:*)", + "Bash(cat > /Users/sjg/git/trx-rs/CONFIGURATION.md << 'ENDOFFILE'\n# Configuration\n\nThis document lists all currently supported configuration options for `trx-server` and `trx-client`.\n\n## File Locations\n\n### `trx-server`\nConfiguration lookup order:\n1. `--config `\n2. `./trx-server.toml`\n3. `~/.trx-server.toml`\n4. `~/.config/trx-rs/server.toml`\n5. `/etc/trx-rs/server.toml`\n\n### `trx-client`\nConfiguration lookup order:\n1. `--config `\n2. `./trx-client.toml`\n3. `~/.config/trx-rs/client.toml`\n4. `/etc/trx-rs/client.toml`\n\nCLI options override file values.\n\n## Environment Variables\n\n- `TRX_PLUGIN_DIRS`: additional plugin directories \\(path-separated\\), used by both server and client.\n\n## `trx-server` Options\n\n### `[general]`\n- `callsign` \\(`string`, default: `\"N0CALL\"`\\)\n- `log_level` \\(`string`, optional\\): one of `trace|debug|info|warn|error`\n- `latitude` \\(`float`, optional\\): `-90..=90`\n- `longitude` \\(`float`, optional\\): `-180..=180`\n\nNotes:\n- `latitude` and `longitude` must be set together or both omitted.\n\n### `[rig]`\n- `model` \\(`string`, required effectively unless provided by CLI `--rig`\\)\n- `initial_freq_hz` \\(`u64`, default: `144300000`, must be `> 0`\\)\n- `initial_mode` \\(`string`, default: `\"USB\"`\\): one of `LSB|USB|CW|CWR|AM|WFM|FM|DIG|PKT`\n\n### `[rig.access]`\n- `type` \\(`string`, default behavior: `serial` if omitted\\): `serial|tcp|sdr`\n- Serial mode:\n - `port` \\(`string`\\)\n - `baud` \\(`u32`\\)\n- TCP mode:\n - `host` \\(`string`\\)\n - `tcp_port` \\(`u16`\\)\n- SDR mode:\n - `args` \\(`string`, required when `type = \"sdr\"`\\): SoapySDR device args string \\(e.g. `\"driver=rtlsdr\"` or `\"driver=airspy,serial=00000001\"`\\). Passed verbatim to `SoapySDR::Device::new\\(\\)`.\n\nNotes:\n- For `serial`, both `port` and `baud` are required.\n- For `tcp`, both `host` and `tcp_port` are required.\n- For `sdr`, `args` must be non-empty. The `port`, `baud`, `host`, and `tcp_port` fields are ignored.\n\n### `[behavior]`\n- `poll_interval_ms` \\(`u64`, default: `500`, must be `> 0`\\)\n- `poll_interval_tx_ms` \\(`u64`, default: `100`, must be `> 0`\\)\n- `max_retries` \\(`u32`, default: `3`, must be `> 0`\\)\n- `retry_base_delay_ms` \\(`u64`, default: `100`, must be `> 0`\\)\n\n### `[listen]`\n- `enabled` \\(`bool`, default: `true`\\)\n- `listen` \\(`ip`, default: `127.0.0.1`\\)\n- `port` \\(`u16`, default: `4530`, must be `> 0` when enabled\\)\n\n### `[listen.auth]`\n- `tokens` \\(`string[]`, default: `[]`\\)\n\nNotes:\n- Empty token strings are invalid.\n- Empty list means no auth required.\n\n### `[audio]`\n- `enabled` \\(`bool`, default: `true`\\)\n- `listen` \\(`ip`, default: `127.0.0.1`\\)\n- `port` \\(`u16`, default: `4531`, must be `> 0` when enabled\\)\n- `rx_enabled` \\(`bool`, default: `true`\\)\n- `tx_enabled` \\(`bool`, default: `true`\\)\n- `device` \\(`string`, optional\\)\n- `sample_rate` \\(`u32`, default: `48000`, valid: `8000..=192000`\\)\n- `channels` \\(`u8`, default: `1`, valid: `1|2`\\)\n- `frame_duration_ms` \\(`u16`, default: `20`, valid: `3|5|10|20|40|60`\\)\n- `bitrate_bps` \\(`u32`, default: `24000`, must be `> 0`\\)\n\nNotes:\n- When `[audio].enabled = true`, at least one of `rx_enabled` or `tx_enabled` must be true.\n\n### `[pskreporter]`\n- `enabled` \\(`bool`, default: `false`\\)\n- `host` \\(`string`, default: `\"report.pskreporter.info\"`, must not be empty when enabled\\)\n- `port` \\(`u16`, default: `4739`, must be `> 0` when enabled\\)\n- `receiver_locator` \\(`string`, optional\\)\n\nNotes:\n- If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`.\n- PSK Reporter software ID is hardcoded to: `trx-server v by SP2SJG`.\n\n### `[aprsfi]`\n- `enabled` \\(`bool`, default: `false`\\)\n- `host` \\(`string`, default: `\"rotate.aprs.net\"`, must not be empty when enabled\\)\n- `port` \\(`u16`, default: `14580`, must be `> 0` when enabled\\)\n- `passcode` \\(`i32`, default: `-1`\\)\n\nNotes:\n- When `passcode = -1` \\(the default\\), the passcode is auto-computed from `[general].callsign` using the standard APRS-IS hash algorithm.\n- `[general].callsign` must be non-empty when `[aprsfi].enabled = true`; otherwise the IGate is silently disabled at startup.\n- Only APRS packets with valid CRC are forwarded; packets from other decoders \\(FT8, WSPR, CW\\) are ignored.\n- The IGate reconnects automatically with exponential backoff \\(1 s → 2 s → … → 60 s\\) on TCP errors.\n- Requires `[audio].enabled = true` \\(APRS packets are decoded from audio\\).\n\n### `[sdr]`\n- `sample_rate` \\(`u32`, default: `1920000`, must be `> 0`\\): IQ capture rate in Hz. Must be supported by the device.\n- `bandwidth` \\(`u32`, default: `1500000`\\): Hardware IF filter bandwidth in Hz.\n- `center_offset_hz` \\(`i64`, default: `100000`\\): The SDR tunes this many Hz below the dial frequency to keep the signal off the DC spur. Negative values tune above.\n\n### `[sdr.gain]`\n- `mode` \\(`string`, default: `\"auto\"`\\): `\"auto\"` enables hardware AGC \\(falls back to `\"manual\"` with a warning if the device does not support it\\); `\"manual\"` uses the fixed `value`.\n- `value` \\(`f64`, default: `30.0`\\): Gain in dB. Used only when `mode = \"manual\"`.\n\n### `[[sdr.channels]]`\n\nDefines one virtual receiver channel within the wideband IQ stream. At least one channel is required when using the `soapysdr` backend. The **first** channel in the list is the *primary* channel: `set_freq` and `set_mode` from rig control apply to it, and `get_status` reads from it.\n\n- `id` \\(`string`, default: `\"\"`\\): Human-readable label used in logs.\n- `offset_hz` \\(`i64`, default: `0`\\): Frequency offset from the dial frequency in Hz. Primary channel should be `0`.\n- `mode` \\(`string`, default: `\"auto\"`\\): Demodulation mode. `\"auto\"` follows the RigCat `set_mode` command; or a fixed mode string: `LSB`, `USB`, `CW`, `CWR`, `AM`, `WFM`, `FM`, `DIG`, `PKT`.\n- `audio_bandwidth_hz` \\(`u32`, default: `3000`\\): One-sided bandwidth of the post-demodulation audio BPF in Hz.\n- `fir_taps` \\(`usize`, default: `64`\\): FIR filter tap count. Higher values give sharper roll-off at the cost of latency.\n- `cw_center_hz` \\(`u32`, default: `700`\\): CW tone centre frequency in the audio domain \\(Hz\\).\n- `wfm_bandwidth_hz` \\(`u32`, default: `75000`\\): Pre-demodulation filter bandwidth for WFM only \\(Hz\\).\n- `decoders` \\(`string[]`, default: `[]`\\): Decoder IDs that receive this channel's PCM audio. Valid values: `\"ft8\"`, `\"wspr\"`, `\"aprs\"`, `\"cw\"`. Each decoder ID may appear in at most one channel.\n- `stream_opus` \\(`bool`, default: `false`\\): Encode this channel's audio as Opus and stream to clients over the TCP audio port. At most one channel may set this to `true`.\n\nNotes:\n- Requires `libSoapySDR` installed \\(`brew install soapysdr` on macOS; `libsoapysdr-dev` on Debian/Ubuntu\\).\n- The SDR backend is RX-only. `[audio] tx_enabled` must be `false`.\n- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2` for every channel; violated channels cause a startup error.\n- `[audio] sample_rate` must match the output audio rate of the SDR pipeline \\(48000 Hz recommended\\).\n- Use `trx-server --print-config` to see all defaults. SDR fields appear only if the binary was built with `--features soapysdr`.\n\n### `[decode_logs]`\n- `enabled` \\(`bool`, default: `false`\\)\n- `dir` \\(`string`, default: `\"$XDG_DATA_HOME/trx-rs/decoders\"`; fallback: `\"logs/decoders\"`, must not be empty when enabled\\)\n- `aprs_file` \\(`string`, default: `\"TRXRS-APRS-%YYYY%-%MM%-%DD%.log\"`, must not be empty when enabled\\)\n- `cw_file` \\(`string`, default: `\"TRXRS-CW-%YYYY%-%MM%-%DD%.log\"`, must not be empty when enabled\\)\n- `ft8_file` \\(`string`, default: `\"TRXRS-FT8-%YYYY%-%MM%-%DD%.log\"`, must not be empty when enabled\\)\n- `wspr_file` \\(`string`, default: `\"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log\"`, must not be empty when enabled\\)\n\nNotes:\n- Decoder logs are server-side and split by decoder name \\(APRS/CW/FT8/WSPR\\).\n- Files are appended in JSON Lines format \\(one JSON object per line\\).\n- Supported filename date tokens: `%YYYY%`, `%MM%`, `%DD%` \\(UTC date\\).\n\n## `trx-client` Options\n\n### `[general]`\n- `callsign` \\(`string`, default: `\"N0CALL\"`\\)\n- `log_level` \\(`string`, optional\\): one of `trace|debug|info|warn|error`\n\n### `[remote]`\n- `url` \\(`string`, optional in file but required at runtime unless provided by CLI `--url`\\)\n- `poll_interval_ms` \\(`u64`, default: `750`, must be `> 0`\\)\n\n### `[remote.auth]`\n- `token` \\(`string`, optional\\)\n\nNotes:\n- If provided, token must not be empty/whitespace.\n\n### `[frontends.http]`\n- `enabled` \\(`bool`, default: `true`\\)\n- `listen` \\(`ip`, default: `127.0.0.1`\\)\n- `port` \\(`u16`, default: `8080`, must be `> 0` when enabled\\)\n\n### `[frontends.rigctl]`\n- `enabled` \\(`bool`, default: `false`\\)\n- `listen` \\(`ip`, default: `127.0.0.1`\\)\n- `port` \\(`u16`, default: `4532`, must be `> 0` when enabled\\)\n\n### `[frontends.http_json]`\n- `enabled` \\(`bool`, default: `true`\\)\n- `listen` \\(`ip`, default: `127.0.0.1`\\)\n- `port` \\(`u16`, default: `0`\\)\n- `auth.tokens` \\(`string[]`, default: `[]`\\)\n\nNotes:\n- `port = 0` means ephemeral bind \\(allowed\\).\n- Empty token strings are invalid.\n\n### `[frontends.audio]`\n- `enabled` \\(`bool`, default: `true`\\)\n- `server_port` \\(`u16`, default: `4531`, must be `> 0` when enabled\\)\n- `bridge.enabled` \\(`bool`, default: `false`\\): enables local `cpal` audio bridge\n- `bridge.rx_output_device` \\(`string`, optional\\): exact local playback device name\n- `bridge.tx_input_device` \\(`string`, optional\\): exact local capture device name\n- `bridge.rx_gain` \\(`float`, default: `1.0`, must be finite and `>= 0`\\)\n- `bridge.tx_gain` \\(`float`, default: `1.0`, must be finite and `>= 0`\\)\n\nNotes:\n- The bridge is intended for local WSJT-X integration via virtual audio devices.\n- Linux: typically use ALSA loopback \\(`snd-aloop`\\).\n- macOS: install a virtual CoreAudio device \\(e.g. BlackHole\\), then set device names above.\n\n## CLI Override Summary\n\n### `trx-server`\n- `--config`, `--print-config`\n- `--rig`, `--access`, positional `RIG_ADDR`\n- `--callsign`\n- `--listen`, `--port` \\(JSON listener\\)\n- SDR backend: all SDR options are file-only \\(`[sdr]` and `[[sdr.channels]]`\\).\n\n### `trx-client`\n- `--config`, `--print-config`\n- `--url`, `--token`, `--poll-interval`\n- `--frontend` \\(comma-separated\\)\n- `--http-listen`, `--http-port`\n- `--rigctl-listen`, `--rigctl-port`\n- `--http-json-listen`, `--http-json-port`\n- `--callsign`\nENDOFFILE)", + "Bash(cd:*)", + "Bash(grep:*)" + ] + } +} diff --git a/.claude/worktrees/agent-a0253fe3 b/.claude/worktrees/agent-a0253fe3 new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-a0253fe3 @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/.claude/worktrees/agent-a119dfd0 b/.claude/worktrees/agent-a119dfd0 new file mode 160000 index 0000000..0008d62 --- /dev/null +++ b/.claude/worktrees/agent-a119dfd0 @@ -0,0 +1 @@ +Subproject commit 0008d62c877ac6af3a93a358bd05517669b9b3ad diff --git a/.claude/worktrees/agent-a13360c6 b/.claude/worktrees/agent-a13360c6 new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-a13360c6 @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/.claude/worktrees/agent-a3ef51e0 b/.claude/worktrees/agent-a3ef51e0 new file mode 160000 index 0000000..5666533 --- /dev/null +++ b/.claude/worktrees/agent-a3ef51e0 @@ -0,0 +1 @@ +Subproject commit 5666533bdb14b6fab33a788933f9b03e2eb2a906 diff --git a/.claude/worktrees/agent-a523ee45 b/.claude/worktrees/agent-a523ee45 new file mode 160000 index 0000000..bdd9a48 --- /dev/null +++ b/.claude/worktrees/agent-a523ee45 @@ -0,0 +1 @@ +Subproject commit bdd9a48207827dd3ed0cc8a0982a75558156930c diff --git a/.claude/worktrees/agent-a7709b41 b/.claude/worktrees/agent-a7709b41 new file mode 160000 index 0000000..80afb92 --- /dev/null +++ b/.claude/worktrees/agent-a7709b41 @@ -0,0 +1 @@ +Subproject commit 80afb928aec43001b2bc2999e5957daa916ce8bd diff --git a/.claude/worktrees/agent-a86b2c7d b/.claude/worktrees/agent-a86b2c7d new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-a86b2c7d @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/.claude/worktrees/agent-aac0b592 b/.claude/worktrees/agent-aac0b592 new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-aac0b592 @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/.claude/worktrees/agent-ab8ff016 b/.claude/worktrees/agent-ab8ff016 new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-ab8ff016 @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/.claude/worktrees/agent-ac624bc4 b/.claude/worktrees/agent-ac624bc4 new file mode 160000 index 0000000..b9005ac --- /dev/null +++ b/.claude/worktrees/agent-ac624bc4 @@ -0,0 +1 @@ +Subproject commit b9005acffdd24801d0a24880dbff7859444b7fe7 diff --git a/.claude/worktrees/agent-ac96f835 b/.claude/worktrees/agent-ac96f835 new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-ac96f835 @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/.claude/worktrees/agent-ae42db96 b/.claude/worktrees/agent-ae42db96 new file mode 160000 index 0000000..80afb92 --- /dev/null +++ b/.claude/worktrees/agent-ae42db96 @@ -0,0 +1 @@ +Subproject commit 80afb928aec43001b2bc2999e5957daa916ce8bd diff --git a/.claude/worktrees/agent-aebe7ef5 b/.claude/worktrees/agent-aebe7ef5 new file mode 160000 index 0000000..0604547 --- /dev/null +++ b/.claude/worktrees/agent-aebe7ef5 @@ -0,0 +1 @@ +Subproject commit 060454780f2d2e54f66a8860ca4442c7cc88d95f diff --git a/aidocs/UI-CAPS.md b/aidocs/UI-CAPS.md index be09d88..9a7cb25 100644 --- a/aidocs/UI-CAPS.md +++ b/aidocs/UI-CAPS.md @@ -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` 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` 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 | --- diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 35b0dbc..0227bec 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -146,6 +146,7 @@ async fn send_command( ) -> RigResult { 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") diff --git a/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs index b505268..45e3e23 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs @@ -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, } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 26b1d2b..9f03f8b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -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]"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 134dcc0..85ef939 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -133,6 +133,26 @@ +
Audio
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 818c607..84edf15 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -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, + rig_tx: web::Data>, +) -> Result { + 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, + rig_tx: web::Data>, +) -> Result { + send_command(&rig_tx, RigCommand::SetFirTaps(query.taps)).await +} + #[post("/toggle_aprs_decode")] pub async fn toggle_aprs_decode( state: web::Data>, @@ -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) -> Result 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(), diff --git a/src/trx-client/trx-frontend/trx-frontend-rigctl/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-rigctl/src/server.rs index b1e6a00..93d1583 100644 --- a/src/trx-client/trx-frontend/trx-frontend-rigctl/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-rigctl/src/server.rs @@ -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, } }