diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 19ed3af..0ad0a99 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -12,9 +12,9 @@ use tokio::time::{self, Instant}; use tracing::{info, warn}; use trx_core::rig::request::RigRequest; -use trx_core::rig::state::{RigState, SpectrumData}; +use trx_core::rig::state::RigState; use trx_core::{RigError, RigResult}; -use trx_frontend::RemoteRigEntry; +use trx_frontend::{RemoteRigEntry, SharedSpectrum}; use trx_protocol::rig_command_to_client; use trx_protocol::types::RigEntry; use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse}; @@ -49,7 +49,7 @@ pub struct RemoteClientConfig { pub known_rigs: Arc>>, pub poll_interval: Duration, /// Shared buffer updated by spectrum polling; None when backend has no spectrum. - pub spectrum: Arc>>, + pub spectrum: Arc>, } pub async fn run_remote_client( @@ -152,13 +152,13 @@ async fn handle_connection( { Ok(snapshot) => { if let Ok(mut guard) = config.spectrum.lock() { - *guard = snapshot.spectrum; + guard.replace(snapshot.spectrum); } } Err(_) => { // Backend may not support spectrum; clear buffer silently. if let Ok(mut guard) = config.spectrum.lock() { - *guard = None; + guard.replace(None); } } } @@ -674,7 +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)), + spectrum: Arc::new(Mutex::new(SharedSpectrum::default())), }, req_rx, state_tx, @@ -710,7 +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)), + spectrum: Arc::new(Mutex::new(SharedSpectrum::default())), }; let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState); assert_eq!(envelope.token.as_deref(), Some("secret")); diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index a22c8a0..1a115c9 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -36,6 +36,23 @@ pub trait FrontendSpawner { ) -> JoinHandle<()>; } +#[derive(Debug, Default)] +pub struct SharedSpectrum { + revision: u64, + frame: Option, +} + +impl SharedSpectrum { + pub fn replace(&mut self, frame: Option) { + self.revision = self.revision.wrapping_add(1); + self.frame = frame; + } + + pub fn snapshot(&self) -> (u64, Option) { + (self.revision, self.frame.clone()) + } +} + pub type FrontendSpawnFn = fn( watch::Receiver, mpsc::Sender, @@ -156,7 +173,7 @@ pub struct FrontendRuntimeContext { /// Owner callsign from trx-client config/CLI for frontend display. pub owner_callsign: Option, /// Latest spectrum frame from the active SDR rig; None for non-SDR backends. - pub spectrum: Arc>>, + pub spectrum: Arc>, } impl FrontendRuntimeContext { @@ -185,7 +202,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)), + spectrum: Arc::new(Mutex::new(SharedSpectrum::default())), } } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 77afb55..5299976 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -27,6 +27,7 @@ --wavelength-fg: #8da3be; --spectrum-bg: #0a0f18; --jog-wheel-size: 83.2px; + --header-waterfall-overlap: 3.2rem; } [data-theme="light"] { @@ -378,10 +379,10 @@ small { color: var(--text-muted); } grid-template-columns: auto minmax(0, 1fr); align-items: center; column-gap: 1rem; - margin-bottom: -0.45rem; + margin-bottom: 0; padding: 0.25rem 0 0.15rem; position: relative; - z-index: 2; + z-index: 3; } .header-text { width: auto; @@ -391,17 +392,20 @@ small { color: var(--text-muted); } .title { font-size: 1.4rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; } .overview-strip { width: 100%; - margin: 0 0 0.95rem; - padding-top: 0.8rem; + margin: calc(-1 * var(--header-waterfall-overlap)) 0 0.95rem; position: relative; z-index: 1; } .overview-toolbar { display: flex; align-items: center; - justify-content: flex-start; + justify-content: flex-end; gap: 0.75rem; - margin-bottom: 0.25rem; + margin-bottom: 0; + position: absolute; + top: calc(var(--header-waterfall-overlap) + 0.2rem); + right: 0.15rem; + z-index: 2; } .overview-label { margin-left: auto; @@ -663,6 +667,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { .card { padding: 0.7rem 0.7rem 1rem; } button { min-height: 2.8rem; font-size: 0.95rem; } input.status-input, select.status-input { font-size: 1.1rem; } + :root { --header-waterfall-overlap: 2.2rem; } .controls-tray { width: max(100%, 52rem); padding-left: 0.85rem; padding-right: 0.85rem; } .freq-inline { gap: 0.5rem; } .freq-inline { flex-wrap: wrap; } @@ -672,7 +677,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { .top-bar-actions { width: 100%; justify-content: space-between; } .header-rig-switch { width: auto; justify-content: flex-end; } .header-rig-switch select { min-width: 6.5rem; } - .overview-toolbar { flex-wrap: wrap; } + .overview-toolbar { top: calc(var(--header-waterfall-overlap) + 0.15rem); } .controls-row { grid-template-columns: 1fr auto; } .controls-col-wfm { grid-column: 1 / -1; } .controls-col-power { grid-column: 1 / -1; } 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 14166b1..f761683 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 @@ -299,28 +299,33 @@ pub async fn spectrum( context: web::Data>, ) -> Result { let context_updates = context.get_ref().clone(); - let mut last_json: Option = None; + let mut last_revision: Option = None; let updates = IntervalStream::new(time::interval(Duration::from_millis(200))).filter_map(move |_| { let context = context_updates.clone(); std::future::ready({ - let next_json = context + let next = context .spectrum .lock() .ok() - .and_then(|g| g.as_ref().and_then(|s| serde_json::to_string(s).ok())); + .map(|g| g.snapshot()); - let payload = match (last_json.as_ref(), next_json) { - (Some(prev), Some(next)) if prev == &next => None, - (_, Some(next)) => { - last_json = Some(next.clone()); - Some(next) + let payload = match next { + Some((revision, frame)) if last_revision == Some(revision) => None, + Some((revision, Some(frame))) => { + last_revision = Some(revision); + serde_json::to_string(&frame).ok() } - (Some(_), None) => { - last_json = None; + Some((revision, None)) => { + last_revision = Some(revision); Some("null".to_string()) } - (None, None) => None, + None if last_revision.is_some() => { + // Lock poisoning is transient; retry instead of breaking stream semantics. + last_revision = None; + Some("null".to_string()) + } + None => None, }; payload.map(|json| Ok::(Bytes::from(format!("data: {json}\n\n"))))