[feat](trx-frontend-http): stream spectrum updates over SSE
Signed-off-by: Stan Grams <sjg@haxx.space> Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
@@ -267,11 +267,11 @@ function applyCapabilities(caps) {
|
|||||||
if (caps.filter_controls) {
|
if (caps.filter_controls) {
|
||||||
spectrumPanel.style.display = "";
|
spectrumPanel.style.display = "";
|
||||||
if (centerFreqField) centerFreqField.style.display = "";
|
if (centerFreqField) centerFreqField.style.display = "";
|
||||||
startSpectrumPolling();
|
startSpectrumStreaming();
|
||||||
} else {
|
} else {
|
||||||
spectrumPanel.style.display = "none";
|
spectrumPanel.style.display = "none";
|
||||||
if (centerFreqField) centerFreqField.style.display = "none";
|
if (centerFreqField) centerFreqField.style.display = "none";
|
||||||
stopSpectrumPolling();
|
stopSpectrumStreaming();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1193,6 +1193,7 @@ function disconnect() {
|
|||||||
decodeSource.close();
|
decodeSource.close();
|
||||||
decodeSource = null;
|
decodeSource = null;
|
||||||
}
|
}
|
||||||
|
stopSpectrumStreaming();
|
||||||
// Clear timers
|
// Clear timers
|
||||||
if (esHeartbeat) {
|
if (esHeartbeat) {
|
||||||
clearInterval(esHeartbeat);
|
clearInterval(esHeartbeat);
|
||||||
@@ -2399,7 +2400,8 @@ window.addEventListener("beforeunload", () => {
|
|||||||
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
||||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
const spectrumTooltip = document.getElementById("spectrum-tooltip");
|
const spectrumTooltip = document.getElementById("spectrum-tooltip");
|
||||||
let spectrumPollTimer = null;
|
let spectrumSource = null;
|
||||||
|
let spectrumReconnectTimer = null;
|
||||||
let lastSpectrumData = null;
|
let lastSpectrumData = null;
|
||||||
|
|
||||||
// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
|
// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
|
||||||
@@ -2446,28 +2448,50 @@ function formatSpectrumFreq(hz) {
|
|||||||
return hz.toFixed(0) + " Hz";
|
return hz.toFixed(0) + " Hz";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling ──────────────────────────────────────────────────────────────────
|
// ── Streaming ────────────────────────────────────────────────────────────────
|
||||||
function startSpectrumPolling() {
|
function scheduleSpectrumReconnect() {
|
||||||
if (spectrumPollTimer !== null) return;
|
if (spectrumReconnectTimer !== null) return;
|
||||||
spectrumPollTimer = setInterval(fetchSpectrum, 200);
|
spectrumReconnectTimer = setTimeout(() => {
|
||||||
fetchSpectrum();
|
spectrumReconnectTimer = null;
|
||||||
|
startSpectrumStreaming();
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopSpectrumPolling() {
|
function startSpectrumStreaming() {
|
||||||
if (spectrumPollTimer !== null) { clearInterval(spectrumPollTimer); spectrumPollTimer = null; }
|
if (spectrumSource !== null) return;
|
||||||
|
spectrumSource = new EventSource("/spectrum");
|
||||||
|
spectrumSource.onmessage = (evt) => {
|
||||||
|
if (evt.data === "null") {
|
||||||
lastSpectrumData = null;
|
lastSpectrumData = null;
|
||||||
clearSpectrumCanvas();
|
clearSpectrumCanvas();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
async function fetchSpectrum() {
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/spectrum", { cache: "no-store" });
|
lastSpectrumData = JSON.parse(evt.data);
|
||||||
if (resp.status === 204) { lastSpectrumData = null; clearSpectrumCanvas(); return; }
|
|
||||||
if (!resp.ok) return;
|
|
||||||
lastSpectrumData = await resp.json();
|
|
||||||
refreshCenterFreqDisplay();
|
refreshCenterFreqDisplay();
|
||||||
drawSpectrum(lastSpectrumData);
|
drawSpectrum(lastSpectrumData);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
spectrumSource.onerror = () => {
|
||||||
|
if (spectrumSource) {
|
||||||
|
spectrumSource.close();
|
||||||
|
spectrumSource = null;
|
||||||
|
}
|
||||||
|
scheduleSpectrumReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSpectrumStreaming() {
|
||||||
|
if (spectrumSource !== null) {
|
||||||
|
spectrumSource.close();
|
||||||
|
spectrumSource = null;
|
||||||
|
}
|
||||||
|
if (spectrumReconnectTimer !== null) {
|
||||||
|
clearTimeout(spectrumReconnectTimer);
|
||||||
|
spectrumReconnectTimer = null;
|
||||||
|
}
|
||||||
|
lastSpectrumData = null;
|
||||||
|
clearSpectrumCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -291,20 +291,53 @@ impl<I> futures_util::Stream for DropStream<I> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight polling endpoint for spectrum data.
|
/// SSE stream for spectrum data.
|
||||||
/// Returns the latest `SpectrumData` as JSON, or 204 No Content if unavailable.
|
/// Emits JSON `SpectrumData` payloads when the latest frame changes.
|
||||||
|
/// Emits `null` when spectrum data becomes unavailable.
|
||||||
#[get("/spectrum")]
|
#[get("/spectrum")]
|
||||||
pub async fn spectrum(
|
pub async fn spectrum(
|
||||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||||
) -> Result<impl Responder, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let data = context.spectrum.lock().ok().and_then(|g| g.clone());
|
let context_updates = context.get_ref().clone();
|
||||||
match data {
|
let updates = IntervalStream::new(time::interval(Duration::from_millis(200))).scan(
|
||||||
Some(s) => Ok(HttpResponse::Ok()
|
None::<String>,
|
||||||
.insert_header((header::CONTENT_TYPE, "application/json"))
|
move |last_json, _| {
|
||||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
let context = context_updates.clone();
|
||||||
.json(s)),
|
std::future::ready({
|
||||||
None => Ok(HttpResponse::NoContent().finish()),
|
let next_json = context
|
||||||
|
.spectrum
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|g| g.as_ref().and_then(|s| serde_json::to_string(s).ok()));
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
(Some(_), None) => {
|
||||||
|
*last_json = None;
|
||||||
|
Some("null".to_string())
|
||||||
|
}
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
payload.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
|
||||||
|
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
|
||||||
|
|
||||||
|
let stream = select(pings, updates);
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||||
|
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||||
|
.insert_header((header::CONNECTION, "keep-alive"))
|
||||||
|
.streaming(stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/toggle_power")]
|
#[post("/toggle_power")]
|
||||||
|
|||||||
@@ -423,11 +423,13 @@ impl RouteAccess {
|
|||||||
|| path == "/rigs"
|
|| path == "/rigs"
|
||||||
|| path == "/events"
|
|| path == "/events"
|
||||||
|| path == "/decode"
|
|| path == "/decode"
|
||||||
|
|| path == "/spectrum"
|
||||||
|| path == "/audio"
|
|| path == "/audio"
|
||||||
|| path.starts_with("/status?")
|
|| path.starts_with("/status?")
|
||||||
|| path.starts_with("/rigs?")
|
|| path.starts_with("/rigs?")
|
||||||
|| path.starts_with("/events?")
|
|| path.starts_with("/events?")
|
||||||
|| path.starts_with("/decode?")
|
|| path.starts_with("/decode?")
|
||||||
|
|| path.starts_with("/spectrum?")
|
||||||
|| path.starts_with("/audio?")
|
|| path.starts_with("/audio?")
|
||||||
{
|
{
|
||||||
return Self::Read;
|
return Self::Read;
|
||||||
@@ -585,6 +587,7 @@ mod tests {
|
|||||||
assert_eq!(RouteAccess::from_path("/rigs"), RouteAccess::Read);
|
assert_eq!(RouteAccess::from_path("/rigs"), RouteAccess::Read);
|
||||||
assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read);
|
assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read);
|
||||||
assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read);
|
assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read);
|
||||||
|
assert_eq!(RouteAccess::from_path("/spectrum"), RouteAccess::Read);
|
||||||
assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read);
|
assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user