feat(trx-client,http-frontend): spectrum waveform with frequency picker
Poll GetSpectrum every 200 ms in remote_client via a dedicated timer that bypasses the main state-watch channel (no SSE noise). The resulting SpectrumData is stored in FrontendRuntimeContext::spectrum and served by a new GET /spectrum endpoint (JSON or 204 when unavailable). HTTP frontend shows a spectrum panel (canvas + frequency axis) only when the rig reports filter_controls=true (i.e. SoapySDR). The canvas renders: - dark background with dBFS grid lines - green FFT spectrum line with semi-transparent fill - red dashed vertical marker at the currently tuned frequency - frequency axis labels (MHz/kHz) below the canvas Clicking the canvas tunes the rig to the clicked frequency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -262,6 +262,7 @@ async fn async_init() -> DynResult<AppState> {
|
||||
selected_rig_id: frontend_runtime.remote_active_rig_id.clone(),
|
||||
known_rigs: frontend_runtime.remote_rigs.clone(),
|
||||
poll_interval: Duration::from_millis(poll_interval_ms),
|
||||
spectrum: frontend_runtime.spectrum.clone(),
|
||||
};
|
||||
let remote_shutdown_rx = shutdown_rx.clone();
|
||||
task_handles.push(tokio::spawn(async move {
|
||||
|
||||
@@ -12,7 +12,7 @@ use tokio::time::{self, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
use trx_core::rig::state::{RigState, SpectrumData};
|
||||
use trx_core::{RigError, RigResult};
|
||||
use trx_frontend::RemoteRigEntry;
|
||||
use trx_protocol::rig_command_to_client;
|
||||
@@ -40,12 +40,16 @@ impl RemoteEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
pub struct RemoteClientConfig {
|
||||
pub addr: String,
|
||||
pub token: Option<String>,
|
||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||
pub poll_interval: Duration,
|
||||
/// Shared buffer updated by spectrum polling; None when backend has no spectrum.
|
||||
pub spectrum: Arc<Mutex<Option<SpectrumData>>>,
|
||||
}
|
||||
|
||||
pub async fn run_remote_client(
|
||||
@@ -107,6 +111,10 @@ async fn handle_connection(
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut poll_interval = time::interval(config.poll_interval);
|
||||
let mut last_poll = Instant::now();
|
||||
let mut spectrum_interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
||||
let mut last_spectrum_poll = Instant::now()
|
||||
.checked_sub(SPECTRUM_POLL_INTERVAL)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
// Prime rig list/state immediately after connect so frontends can render
|
||||
// rig selectors without waiting for the first poll interval.
|
||||
@@ -134,6 +142,27 @@ async fn handle_connection(
|
||||
warn!("Remote poll failed: {}", e);
|
||||
}
|
||||
}
|
||||
_ = spectrum_interval.tick() => {
|
||||
if last_spectrum_poll.elapsed() < SPECTRUM_POLL_INTERVAL {
|
||||
continue;
|
||||
}
|
||||
last_spectrum_poll = Instant::now();
|
||||
match send_command_no_state_update(config, &mut writer, &mut reader,
|
||||
ClientCommand::GetSpectrum).await
|
||||
{
|
||||
Ok(snapshot) => {
|
||||
if let Ok(mut guard) = config.spectrum.lock() {
|
||||
*guard = snapshot.spectrum;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Backend may not support spectrum; clear buffer silently.
|
||||
if let Ok(mut guard) = config.spectrum.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
req = rx.recv() => {
|
||||
let Some(req) = req else {
|
||||
return Ok(());
|
||||
@@ -196,6 +225,46 @@ async fn send_command(
|
||||
))
|
||||
}
|
||||
|
||||
/// Like `send_command` but does NOT update the main `state_tx` watch channel.
|
||||
/// Used for spectrum polling to avoid triggering spurious SSE updates.
|
||||
async fn send_command_no_state_update(
|
||||
config: &RemoteClientConfig,
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
|
||||
cmd: ClientCommand,
|
||||
) -> RigResult<trx_core::RigSnapshot> {
|
||||
let envelope = build_envelope(config, cmd);
|
||||
let payload = serde_json::to_string(&envelope)
|
||||
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
||||
time::timeout(
|
||||
IO_TIMEOUT,
|
||||
writer.write_all(format!("{}\n", payload).as_bytes()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
||||
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
||||
time::timeout(IO_TIMEOUT, writer.flush())
|
||||
.await
|
||||
.map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))?
|
||||
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
|
||||
let line = time::timeout(IO_TIMEOUT, read_limited_line(reader, MAX_JSON_LINE_BYTES))
|
||||
.await
|
||||
.map_err(|_| RigError::communication(format!("read timed out after {:?}", IO_TIMEOUT)))?
|
||||
.map_err(|e| RigError::communication(format!("read failed: {e}")))?;
|
||||
let line = line.ok_or_else(|| RigError::communication("connection closed by remote"))?;
|
||||
let resp: ClientResponse = serde_json::from_str(line.trim_end())
|
||||
.map_err(|e| RigError::communication(format!("invalid response: {e}")))?;
|
||||
if resp.success {
|
||||
if let Some(snapshot) = resp.state {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
return Err(RigError::communication("missing snapshot"));
|
||||
}
|
||||
Err(RigError::communication(
|
||||
resp.error.unwrap_or_else(|| "remote error".into()),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_envelope(config: &RemoteClientConfig, cmd: ClientCommand) -> ClientEnvelope {
|
||||
ClientEnvelope {
|
||||
token: config.token.clone(),
|
||||
@@ -547,6 +616,7 @@ mod tests {
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
filter: None,
|
||||
spectrum: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +632,7 @@ mod tests {
|
||||
state: None,
|
||||
rigs: Some(vec![RigEntry {
|
||||
rig_id: "default".to_string(),
|
||||
display_name: None,
|
||||
state: snapshot.clone(),
|
||||
audio_port: Some(4531),
|
||||
}]),
|
||||
@@ -603,6 +674,7 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(100),
|
||||
spectrum: Arc::new(Mutex::new(None)),
|
||||
},
|
||||
req_rx,
|
||||
state_tx,
|
||||
@@ -638,6 +710,7 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(500),
|
||||
spectrum: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState);
|
||||
assert_eq!(envelope.token.as_deref(), Some("secret"));
|
||||
|
||||
@@ -14,7 +14,7 @@ use tokio::task::JoinHandle;
|
||||
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
use trx_core::decode::{AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::rig::state::RigSnapshot;
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -155,6 +155,8 @@ pub struct FrontendRuntimeContext {
|
||||
pub remote_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||
/// Owner callsign from trx-client config/CLI for frontend display.
|
||||
pub owner_callsign: Option<String>,
|
||||
/// Latest spectrum frame from the active SDR rig; None for non-SDR backends.
|
||||
pub spectrum: Arc<Mutex<Option<SpectrumData>>>,
|
||||
}
|
||||
|
||||
impl FrontendRuntimeContext {
|
||||
@@ -183,6 +185,7 @@ impl FrontendRuntimeContext {
|
||||
remote_active_rig_id: Arc::new(Mutex::new(None)),
|
||||
remote_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
owner_callsign: None,
|
||||
spectrum: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,7 @@ mod tests {
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
filter: None,
|
||||
spectrum: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,18 @@ function applyCapabilities(caps) {
|
||||
// Filters panel
|
||||
const filtersPanel = document.getElementById("filters-panel");
|
||||
if (filtersPanel) filtersPanel.style.display = caps.filter_controls ? "" : "none";
|
||||
|
||||
// Spectrum panel (SDR-only)
|
||||
const spectrumPanel = document.getElementById("spectrum-panel");
|
||||
if (spectrumPanel) {
|
||||
if (caps.filter_controls) {
|
||||
spectrumPanel.style.display = "";
|
||||
startSpectrumPolling();
|
||||
} else {
|
||||
spectrumPanel.style.display = "none";
|
||||
stopSpectrumPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const freqEl = document.getElementById("freq");
|
||||
@@ -2329,3 +2341,171 @@ window.addEventListener("beforeunload", () => {
|
||||
navigator.sendBeacon("/set_ptt?ptt=false", "");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Spectrum display ────────────────────────────────────────────────────────
|
||||
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||
let spectrumPollTimer = null;
|
||||
let lastSpectrumData = null;
|
||||
|
||||
function startSpectrumPolling() {
|
||||
if (spectrumPollTimer !== null) return;
|
||||
spectrumPollTimer = setInterval(fetchSpectrum, 200);
|
||||
fetchSpectrum();
|
||||
}
|
||||
|
||||
function stopSpectrumPolling() {
|
||||
if (spectrumPollTimer !== null) {
|
||||
clearInterval(spectrumPollTimer);
|
||||
spectrumPollTimer = null;
|
||||
}
|
||||
lastSpectrumData = null;
|
||||
clearSpectrumCanvas();
|
||||
}
|
||||
|
||||
async function fetchSpectrum() {
|
||||
try {
|
||||
const resp = await fetch("/spectrum", { cache: "no-store" });
|
||||
if (resp.status === 204) {
|
||||
lastSpectrumData = null;
|
||||
clearSpectrumCanvas();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
lastSpectrumData = data;
|
||||
drawSpectrum(data);
|
||||
} catch (_) {
|
||||
// ignore fetch errors (connection lost etc.)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSpectrumCanvas() {
|
||||
if (!spectrumCanvas) return;
|
||||
const ctx = spectrumCanvas.getContext("2d");
|
||||
const w = spectrumCanvas.width, h = spectrumCanvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = "#0a0f18";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawSpectrum(data) {
|
||||
if (!spectrumCanvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = spectrumCanvas.clientWidth || 600;
|
||||
const cssH = spectrumCanvas.clientHeight || 120;
|
||||
const W = Math.round(cssW * dpr);
|
||||
const H = Math.round(cssH * dpr);
|
||||
if (spectrumCanvas.width !== W || spectrumCanvas.height !== H) {
|
||||
spectrumCanvas.width = W;
|
||||
spectrumCanvas.height = H;
|
||||
}
|
||||
|
||||
const ctx = spectrumCanvas.getContext("2d");
|
||||
// Background
|
||||
ctx.fillStyle = "#0a0f18";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const bins = data.bins;
|
||||
const n = bins.length;
|
||||
if (!n) return;
|
||||
|
||||
// dBFS range for display
|
||||
const DB_MIN = -80;
|
||||
const DB_MAX = 0;
|
||||
const dbRange = DB_MAX - DB_MIN;
|
||||
|
||||
// Grid lines (horizontal dBFS)
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let db = DB_MIN; db <= DB_MAX; db += 20) {
|
||||
const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Spectrum line
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = "#00e676";
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * W;
|
||||
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
||||
const y = H * (1 - (db - DB_MIN) / dbRange);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Fill under spectrum line
|
||||
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath();
|
||||
ctx.fillStyle = "rgba(0,230,118,0.08)";
|
||||
ctx.fill();
|
||||
|
||||
// Tuned-frequency marker
|
||||
if (lastFreqHz != null && data.center_hz && data.sample_rate) {
|
||||
const halfBw = data.sample_rate / 2;
|
||||
const loHz = data.center_hz - halfBw;
|
||||
const hiHz = data.center_hz + halfBw;
|
||||
const frac = (lastFreqHz - loHz) / (hiHz - loHz);
|
||||
if (frac >= 0 && frac <= 1) {
|
||||
const xf = Math.round(frac * W);
|
||||
ctx.save();
|
||||
ctx.setLineDash([4 * dpr, 4 * dpr]);
|
||||
ctx.strokeStyle = "#ff1744";
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
ctx.beginPath(); ctx.moveTo(xf, 0); ctx.lineTo(xf, H); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency axis labels
|
||||
updateSpectrumFreqAxis(data);
|
||||
}
|
||||
|
||||
function updateSpectrumFreqAxis(data) {
|
||||
if (!spectrumFreqAxis || !data.center_hz || !data.sample_rate) return;
|
||||
const halfBw = data.sample_rate / 2;
|
||||
const loHz = data.center_hz - halfBw;
|
||||
const hiHz = data.center_hz + halfBw;
|
||||
|
||||
// Choose label step: aim for ~5 labels
|
||||
const spanMHz = (hiHz - loHz) / 1e6;
|
||||
let stepMHz = 1;
|
||||
if (spanMHz <= 1) stepMHz = 0.1;
|
||||
else if (spanMHz <= 2) stepMHz = 0.2;
|
||||
else if (spanMHz <= 5) stepMHz = 0.5;
|
||||
else if (spanMHz <= 10) stepMHz = 1;
|
||||
else if (spanMHz <= 20) stepMHz = 2;
|
||||
else stepMHz = 5;
|
||||
|
||||
const stepHz = stepMHz * 1e6;
|
||||
const firstHz = Math.ceil(loHz / stepHz) * stepHz;
|
||||
|
||||
// Rebuild axis spans
|
||||
spectrumFreqAxis.innerHTML = "";
|
||||
for (let hz = firstHz; hz <= hiHz; hz += stepHz) {
|
||||
const frac = (hz - loHz) / (hiHz - loHz);
|
||||
const pct = (frac * 100).toFixed(2);
|
||||
const label = hz >= 1e6
|
||||
? (hz / 1e6).toFixed(stepMHz < 1 ? 1 : 0) + " MHz"
|
||||
: (hz / 1e3).toFixed(0) + " kHz";
|
||||
const span = document.createElement("span");
|
||||
span.textContent = label;
|
||||
span.style.left = pct + "%";
|
||||
spectrumFreqAxis.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Click on spectrum canvas → tune to that frequency
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.addEventListener("click", (e) => {
|
||||
if (!lastSpectrumData || !lastSpectrumData.center_hz || !lastSpectrumData.sample_rate) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
const frac = (e.clientX - rect.left) / rect.width;
|
||||
const halfBw = lastSpectrumData.sample_rate / 2;
|
||||
const loHz = lastSpectrumData.center_hz - halfBw;
|
||||
const hiHz = lastSpectrumData.center_hz + halfBw;
|
||||
const targetHz = Math.round(loHz + frac * (hiHz - loHz));
|
||||
setFreq(targetHz);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,6 +158,13 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spectrum-panel" style="display:none;">
|
||||
<div class="label"><span>Spectrum</span></div>
|
||||
<div class="spectrum-wrap">
|
||||
<canvas id="spectrum-canvas"></canvas>
|
||||
<div id="spectrum-freq-axis"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="audio-row">
|
||||
<div class="label"><span>Audio</span></div>
|
||||
|
||||
@@ -583,3 +583,31 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.vfo-picker button { border-right: none; border-bottom: 1px solid var(--border-light); }
|
||||
.vfo-picker button:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
|
||||
/* ── Spectrum display ─────────────────────────────────────────────────── */
|
||||
.spectrum-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
#spectrum-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background: #0a0f18;
|
||||
border-radius: 4px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
#spectrum-freq-axis {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
width: 100%;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
#spectrum-freq-axis span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -291,6 +291,22 @@ impl<I> futures_util::Stream for DropStream<I> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight polling endpoint for spectrum data.
|
||||
/// Returns the latest `SpectrumData` as JSON, or 204 No Content if unavailable.
|
||||
#[get("/spectrum")]
|
||||
pub async fn spectrum(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
let data = context.spectrum.lock().ok().and_then(|g| g.clone());
|
||||
match data {
|
||||
Some(s) => Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "application/json"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.json(s)),
|
||||
None => Ok(HttpResponse::NoContent().finish()),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/toggle_power")]
|
||||
pub async fn toggle_power(
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
@@ -611,6 +627,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(list_rigs)
|
||||
.service(events)
|
||||
.service(decode_events)
|
||||
.service(spectrum)
|
||||
.service(toggle_power)
|
||||
.service(toggle_vfo)
|
||||
.service(lock_panel)
|
||||
@@ -805,6 +822,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
|
||||
ft8_decode_enabled: state.ft8_decode_enabled,
|
||||
wspr_decode_enabled: state.wspr_decode_enabled,
|
||||
filter: state.filter.clone(),
|
||||
spectrum: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -662,6 +662,7 @@ mod tests {
|
||||
cw_wpm: 0,
|
||||
cw_tone_hz: 0,
|
||||
filter: None,
|
||||
spectrum: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user