Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
T
sjg 616ff1b79e [feat](trx-backend-soapysdr): add wfm denoise levels
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:22:02 +01:00

1156 lines
35 KiB
Rust

// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
use actix_web::{http::header, Error};
use bytes::Bytes;
use futures_util::stream::{once, select, StreamExt};
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use tokio::time::{self, Duration};
use tokio_stream::wrappers::{IntervalStream, WatchStream};
use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
use trx_core::{RigCommand, RigRequest, RigSnapshot, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::{parse_mode, ClientResponse};
use crate::server::status;
const FAVICON_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/trx-favicon.png"
));
const LOGO_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-logo.png"));
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
struct FrontendMeta {
http_clients: usize,
rigctl_clients: usize,
rigctl_addr: Option<String>,
active_rig_id: Option<String>,
rig_ids: Vec<String>,
owner_callsign: Option<String>,
owner_website_url: Option<String>,
owner_website_name: Option<String>,
show_sdr_gain_control: bool,
initial_map_zoom: u8,
}
#[get("/status")]
pub async fn status_api(
state: web::Data<watch::Receiver<RigState>>,
clients: web::Data<Arc<AtomicUsize>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<impl Responder, Error> {
let state = wait_for_view(state.get_ref().clone()).await?;
let json = serde_json::to_string(&state).map_err(actix_web::error::ErrorInternalServerError)?;
let json = inject_frontend_meta(
&json,
frontend_meta_from_context(clients.load(Ordering::Relaxed), context.get_ref().as_ref()),
);
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/json"))
.body(json))
}
/// Inject `"clients": N` into a JSON object string.
fn inject_frontend_meta(json: &str, meta: FrontendMeta) -> String {
let mut value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return json.to_string(),
};
let Some(map) = value.as_object_mut() else {
return json.to_string();
};
map.insert("clients".to_string(), serde_json::json!(meta.http_clients));
map.insert(
"rigctl_clients".to_string(),
serde_json::json!(meta.rigctl_clients),
);
if let Some(addr) = meta.rigctl_addr {
map.insert("rigctl_addr".to_string(), serde_json::json!(addr));
}
if let Some(rig_id) = meta.active_rig_id {
map.insert("active_rig_id".to_string(), serde_json::json!(rig_id));
}
map.insert("rig_ids".to_string(), serde_json::json!(meta.rig_ids));
if let Some(owner) = meta.owner_callsign {
map.insert("owner_callsign".to_string(), serde_json::json!(owner));
}
if let Some(url) = meta.owner_website_url {
map.insert("owner_website_url".to_string(), serde_json::json!(url));
}
if let Some(name) = meta.owner_website_name {
map.insert("owner_website_name".to_string(), serde_json::json!(name));
}
map.insert(
"show_sdr_gain_control".to_string(),
serde_json::json!(meta.show_sdr_gain_control),
);
map.insert(
"initial_map_zoom".to_string(),
serde_json::json!(meta.initial_map_zoom),
);
serde_json::to_string(&value).unwrap_or_else(|_| json.to_string())
}
fn frontend_meta_from_context(
http_clients: usize,
context: &FrontendRuntimeContext,
) -> FrontendMeta {
FrontendMeta {
http_clients,
rigctl_clients: context.rigctl_clients.load(Ordering::Relaxed),
rigctl_addr: rigctl_addr_from_context(context),
active_rig_id: active_rig_id_from_context(context),
rig_ids: rig_ids_from_context(context),
owner_callsign: owner_callsign_from_context(context),
owner_website_url: owner_website_url_from_context(context),
owner_website_name: owner_website_name_from_context(context),
show_sdr_gain_control: show_sdr_gain_control_from_context(context),
initial_map_zoom: initial_map_zoom_from_context(context),
}
}
fn rigctl_addr_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context
.rigctl_listen_addr
.lock()
.ok()
.and_then(|v| *v)
.map(|addr| addr.to_string())
}
fn active_rig_id_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context
.remote_active_rig_id
.lock()
.ok()
.and_then(|v| v.clone())
}
fn rig_ids_from_context(context: &FrontendRuntimeContext) -> Vec<String> {
context
.remote_rigs
.lock()
.ok()
.map(|entries| entries.iter().map(|r| r.rig_id.clone()).collect())
.unwrap_or_default()
}
fn owner_callsign_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.owner_callsign.clone()
}
fn owner_website_url_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.owner_website_url.clone()
}
fn owner_website_name_from_context(context: &FrontendRuntimeContext) -> Option<String> {
context.owner_website_name.clone()
}
fn show_sdr_gain_control_from_context(context: &FrontendRuntimeContext) -> bool {
context.http_show_sdr_gain_control
}
fn initial_map_zoom_from_context(context: &FrontendRuntimeContext) -> u8 {
context.http_initial_map_zoom
}
#[get("/events")]
pub async fn events(
state: web::Data<watch::Receiver<RigState>>,
clients: web::Data<Arc<AtomicUsize>>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let rx = state.get_ref().clone();
let initial = wait_for_view(rx.clone()).await?;
let counter = clients.get_ref().clone();
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
let initial_json =
serde_json::to_string(&initial).map_err(actix_web::error::ErrorInternalServerError)?;
let initial_json = inject_frontend_meta(
&initial_json,
frontend_meta_from_context(count, context.get_ref().as_ref()),
);
let initial_stream =
once(async move { Ok::<Bytes, Error>(Bytes::from(format!("data: {initial_json}\n\n"))) });
let counter_updates = counter.clone();
let context_updates = context.get_ref().clone();
let updates = WatchStream::new(rx).filter_map(move |state| {
let counter = counter_updates.clone();
let context = context_updates.clone();
async move {
state.snapshot().and_then(|v| {
serde_json::to_string(&v).ok().map(|json| {
let json = inject_frontend_meta(
&json,
frontend_meta_from_context(
counter.load(Ordering::Relaxed),
context.as_ref(),
),
);
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n")))
})
})
}
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(5)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
// Wrap stream to decrement counter on drop.
let counter_drop = counter.clone();
let stream = initial_stream.chain(select(pings, updates));
let stream = DropStream::new(Box::pin(stream), move || {
counter_drop.fetch_sub(1, Ordering::Relaxed);
});
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))
}
#[get("/decode")]
pub async fn decode_events(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let Some(decode_rx) = crate::server::audio::subscribe_decode(context.get_ref()) else {
tracing::warn!("/decode requested but decode channel not set (audio disabled?)");
return Ok(HttpResponse::NotFound().body("decode not enabled"));
};
tracing::info!("/decode SSE client connected");
let history = {
let mut out = Vec::new();
out.extend(
crate::server::audio::snapshot_aprs_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::Aprs),
);
out.extend(
crate::server::audio::snapshot_cw_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::Cw),
);
out.extend(
crate::server::audio::snapshot_ft8_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::Ft8),
);
out.extend(
crate::server::audio::snapshot_wspr_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::Wspr),
);
out
};
let history_stream = futures_util::stream::iter(history.into_iter().filter_map(|msg| {
serde_json::to_string(&msg)
.ok()
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
}));
let decode_stream = futures_util::stream::unfold(decode_rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(msg) => {
if let Ok(json) = serde_json::to_string(&msg) {
return Some((
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))),
rx,
));
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
let stream = history_stream.chain(select(pings, decode_stream));
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))
}
/// A stream wrapper that calls a callback when dropped.
struct DropStream<I> {
inner: std::pin::Pin<Box<dyn futures_util::Stream<Item = I> + 'static>>,
on_drop: Option<Box<dyn FnOnce() + Send>>,
}
impl<I> DropStream<I> {
fn new<S, F>(inner: std::pin::Pin<Box<S>>, on_drop: F) -> Self
where
S: futures_util::Stream<Item = I> + 'static,
F: FnOnce() + Send + 'static,
{
Self {
inner,
on_drop: Some(Box::new(on_drop)),
}
}
}
impl<I> Drop for DropStream<I> {
fn drop(&mut self) {
if let Some(f) = self.on_drop.take() {
f();
}
}
}
impl<I> futures_util::Stream for DropStream<I> {
type Item = I;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
/// SSE stream for spectrum data.
/// Emits JSON `SpectrumData` payloads when the latest frame changes.
/// Emits `null` when spectrum data becomes unavailable.
#[get("/spectrum")]
pub async fn spectrum(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let context_updates = context.get_ref().clone();
let mut last_revision: Option<u64> = None;
let updates =
IntervalStream::new(time::interval(Duration::from_millis(40))).filter_map(move |_| {
let context = context_updates.clone();
std::future::ready({
let next = context.spectrum.lock().ok().map(|g| g.snapshot());
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((revision, None)) => {
last_revision = Some(revision);
Some("null".to_string())
}
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, 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")]
pub async fn toggle_power(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let desired_on = !matches!(state.get_ref().borrow().control.enabled, Some(true));
let cmd = if desired_on {
RigCommand::PowerOn
} else {
RigCommand::PowerOff
};
send_command(&rig_tx, cmd).await
}
#[post("/toggle_vfo")]
pub async fn toggle_vfo(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::ToggleVfo).await
}
#[post("/lock")]
pub async fn lock_panel(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::Lock).await
}
#[post("/unlock")]
pub async fn unlock_panel(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::Unlock).await
}
#[derive(serde::Deserialize)]
pub struct FreqQuery {
pub hz: u64,
}
#[post("/set_freq")]
pub async fn set_freq(
query: web::Query<FreqQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: query.hz })).await
}
#[post("/set_center_freq")]
pub async fn set_center_freq(
query: web::Query<FreqQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCenterFreq(Freq { hz: query.hz })).await
}
#[derive(serde::Deserialize)]
pub struct ModeQuery {
pub mode: String,
}
#[post("/set_mode")]
pub async fn set_mode(
query: web::Query<ModeQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let mode = parse_mode(&query.mode);
send_command(&rig_tx, RigCommand::SetMode(mode)).await
}
#[derive(serde::Deserialize)]
pub struct PttQuery {
pub ptt: String,
}
#[post("/set_ptt")]
pub async fn set_ptt(
query: web::Query<PttQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let ptt = match query.ptt.to_ascii_lowercase().as_str() {
"1" | "true" | "on" => Ok(true),
"0" | "false" | "off" => Ok(false),
other => Err(actix_web::error::ErrorBadRequest(format!(
"invalid ptt parameter: {other}"
))),
}?;
send_command(&rig_tx, RigCommand::SetPtt(ptt)).await
}
#[derive(serde::Deserialize)]
pub struct TxLimitQuery {
pub limit: u8,
}
#[post("/set_tx_limit")]
pub async fn set_tx_limit(
query: web::Query<TxLimitQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
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
}
#[derive(serde::Deserialize)]
pub struct SdrGainQuery {
pub db: f64,
}
#[post("/set_sdr_gain")]
pub async fn set_sdr_gain(
query: web::Query<SdrGainQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetSdrGain(query.db)).await
}
#[derive(serde::Deserialize)]
pub struct WfmDeemphasisQuery {
pub us: u32,
}
#[post("/set_wfm_deemphasis")]
pub async fn set_wfm_deemphasis(
query: web::Query<WfmDeemphasisQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetWfmDeemphasis(query.us)).await
}
#[derive(serde::Deserialize)]
pub struct WfmStereoQuery {
pub enabled: bool,
}
#[post("/set_wfm_stereo")]
pub async fn set_wfm_stereo(
query: web::Query<WfmStereoQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetWfmStereo(query.enabled)).await
}
#[derive(serde::Deserialize)]
pub struct WfmDenoiseQuery {
pub level: WfmDenoiseLevel,
}
#[post("/set_wfm_denoise")]
pub async fn set_wfm_denoise(
query: web::Query<WfmDenoiseQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetWfmDenoise(query.level)).await
}
#[post("/toggle_aprs_decode")]
pub async fn toggle_aprs_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().aprs_decode_enabled;
send_command(&rig_tx, RigCommand::SetAprsDecodeEnabled(!enabled)).await
}
#[post("/toggle_cw_decode")]
pub async fn toggle_cw_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().cw_decode_enabled;
send_command(&rig_tx, RigCommand::SetCwDecodeEnabled(!enabled)).await
}
#[derive(serde::Deserialize)]
pub struct CwAutoQuery {
pub enabled: bool,
}
#[post("/set_cw_auto")]
pub async fn set_cw_auto(
query: web::Query<CwAutoQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCwAuto(query.enabled)).await
}
#[derive(serde::Deserialize)]
pub struct CwWpmQuery {
pub wpm: u32,
}
#[post("/set_cw_wpm")]
pub async fn set_cw_wpm(
query: web::Query<CwWpmQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCwWpm(query.wpm)).await
}
#[derive(serde::Deserialize)]
pub struct CwToneQuery {
pub tone_hz: u32,
}
#[post("/set_cw_tone")]
pub async fn set_cw_tone(
query: web::Query<CwToneQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetCwToneHz(query.tone_hz)).await
}
#[post("/toggle_ft8_decode")]
pub async fn toggle_ft8_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().ft8_decode_enabled;
send_command(&rig_tx, RigCommand::SetFt8DecodeEnabled(!enabled)).await
}
#[post("/toggle_wspr_decode")]
pub async fn toggle_wspr_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().wspr_decode_enabled;
send_command(&rig_tx, RigCommand::SetWsprDecodeEnabled(!enabled)).await
}
#[post("/clear_ft8_decode")]
pub async fn clear_ft8_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ft8_history(context.get_ref());
send_command(&rig_tx, RigCommand::ResetFt8Decoder).await
}
#[post("/clear_wspr_decode")]
pub async fn clear_wspr_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_wspr_history(context.get_ref());
send_command(&rig_tx, RigCommand::ResetWsprDecoder).await
}
#[post("/clear_aprs_decode")]
pub async fn clear_aprs_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_aprs_history(context.get_ref());
send_command(&rig_tx, RigCommand::ResetAprsDecoder).await
}
#[post("/clear_cw_decode")]
pub async fn clear_cw_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_cw_history(context.get_ref());
send_command(&rig_tx, RigCommand::ResetCwDecoder).await
}
// ============================================================================
// Bookmark CRUD endpoints
// ============================================================================
#[derive(serde::Deserialize)]
pub struct BookmarkQuery {
pub category: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct BookmarkInput {
pub name: String,
pub freq_hz: u64,
pub mode: String,
pub bandwidth_hz: Option<u64>,
pub comment: Option<String>,
pub category: Option<String>,
pub decoders: Option<Vec<String>>,
}
fn require_control(
req: &HttpRequest,
auth_state: &crate::server::auth::AuthState,
) -> Result<(), Error> {
if !auth_state.config.enabled {
return Ok(());
}
match crate::server::auth::get_session_role(req, auth_state) {
Some(crate::server::auth::AuthRole::Control) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("control role required")),
}
}
fn gen_bookmark_id() -> String {
hex::encode(rand::random::<[u8; 16]>())
}
#[get("/bookmarks")]
pub async fn list_bookmarks(
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
query: web::Query<BookmarkQuery>,
) -> Result<HttpResponse, Error> {
let mut list = store.list();
if let Some(ref cat) = query.category {
if !cat.is_empty() {
let cat_lower = cat.to_lowercase();
list.retain(|bm| bm.category.to_lowercase() == cat_lower);
}
}
list.sort_by(|a, b| a.name.cmp(&b.name));
Ok(HttpResponse::Ok().json(list))
}
#[post("/bookmarks")]
pub async fn create_bookmark(
req: HttpRequest,
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let bm = crate::server::bookmarks::Bookmark {
id: gen_bookmark_id(),
name: body.name.clone(),
freq_hz: body.freq_hz,
mode: body.mode.clone(),
bandwidth_hz: body.bandwidth_hz,
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
};
if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm))
} else {
Err(actix_web::error::ErrorInternalServerError(
"failed to save bookmark",
))
}
}
#[put("/bookmarks/{id}")]
pub async fn update_bookmark(
req: HttpRequest,
path: web::Path<String>,
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let id = path.into_inner();
let bm = crate::server::bookmarks::Bookmark {
id: id.clone(),
name: body.name.clone(),
freq_hz: body.freq_hz,
mode: body.mode.clone(),
bandwidth_hz: body.bandwidth_hz,
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
};
if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm))
} else {
Err(actix_web::error::ErrorNotFound("bookmark not found"))
}
}
#[delete("/bookmarks/{id}")]
pub async fn delete_bookmark(
req: HttpRequest,
path: web::Path<String>,
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
let id = path.into_inner();
if store.remove(&id) {
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true })))
} else {
Err(actix_web::error::ErrorNotFound("bookmark not found"))
}
}
#[derive(serde::Serialize)]
struct RigListItem {
rig_id: String,
display_name: Option<String>,
manufacturer: String,
model: String,
initialized: bool,
}
#[derive(serde::Serialize)]
struct RigListResponse {
active_rig_id: Option<String>,
rigs: Vec<RigListItem>,
}
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
let active_rig_id = active_rig_id_from_context(context);
let rigs = context
.remote_rigs
.lock()
.ok()
.map(|entries| entries.iter().map(map_rig_entry).collect())
.unwrap_or_default();
RigListResponse {
active_rig_id,
rigs,
}
}
fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
RigListItem {
rig_id: entry.rig_id.clone(),
display_name: entry.display_name.clone(),
manufacturer: entry.state.info.manufacturer.clone(),
model: entry.state.info.model.clone(),
initialized: entry.state.initialized,
}
}
#[get("/rigs")]
pub async fn list_rigs(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
}
#[derive(serde::Deserialize)]
pub struct SelectRigQuery {
pub rig_id: String,
}
#[post("/select_rig")]
pub async fn select_rig(
query: web::Query<SelectRigQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let rig_id = query.rig_id.trim();
if rig_id.is_empty() {
return Err(actix_web::error::ErrorBadRequest(
"rig_id must not be empty",
));
}
let known = context
.remote_rigs
.lock()
.ok()
.map(|entries| entries.iter().any(|entry| entry.rig_id == rig_id))
.unwrap_or(false);
if !known {
return Err(actix_web::error::ErrorBadRequest(format!(
"unknown rig_id: {rig_id}"
)));
}
if let Ok(mut active) = context.remote_active_rig_id.lock() {
*active = Some(rig_id.to_string());
}
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(index)
.service(status_api)
.service(list_rigs)
.service(events)
.service(decode_events)
.service(spectrum)
.service(toggle_power)
.service(toggle_vfo)
.service(lock_panel)
.service(unlock_panel)
.service(set_freq)
.service(set_center_freq)
.service(set_mode)
.service(set_ptt)
.service(set_tx_limit)
.service(set_bandwidth)
.service(set_fir_taps)
.service(set_sdr_gain)
.service(set_wfm_deemphasis)
.service(set_wfm_stereo)
.service(set_wfm_denoise)
.service(toggle_aprs_decode)
.service(toggle_cw_decode)
.service(set_cw_auto)
.service(set_cw_wpm)
.service(set_cw_tone)
.service(toggle_ft8_decode)
.service(toggle_wspr_decode)
.service(clear_aprs_decode)
.service(clear_cw_decode)
.service(clear_ft8_decode)
.service(clear_wspr_decode)
.service(select_rig)
// Bookmark CRUD
.service(list_bookmarks)
.service(create_bookmark)
.service(update_bookmark)
.service(delete_bookmark)
.service(crate::server::audio::audio_ws)
.service(favicon)
.service(favicon_png)
.service(logo)
.service(style_css)
.service(app_js)
.service(aprs_js)
.service(ft8_js)
.service(wspr_js)
.service(cw_js)
.service(bookmarks_js)
// Auth endpoints
.service(crate::server::auth::login)
.service(crate::server::auth::logout)
.service(crate::server::auth::session_status);
}
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8"))
.body(status::index_html())
}
#[get("/favicon.ico")]
async fn favicon() -> impl Responder {
HttpResponse::TemporaryRedirect()
.insert_header((header::LOCATION, "/favicon.png?v=4"))
.finish()
}
#[get("/favicon.png")]
async fn favicon_png() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.body(FAVICON_BYTES)
}
#[get("/logo.png")]
async fn logo() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.body(LOGO_BYTES)
}
#[get("/style.css")]
async fn style_css() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/css; charset=utf-8"))
.body(status::STYLE_CSS)
}
#[get("/app.js")]
async fn app_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::APP_JS)
}
#[get("/aprs.js")]
async fn aprs_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::APRS_JS)
}
#[get("/ft8.js")]
async fn ft8_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::FT8_JS)
}
#[get("/wspr.js")]
async fn wspr_js() -> impl Responder {
HttpResponse::Ok()
.content_type("application/javascript; charset=utf-8")
.insert_header((header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"))
.insert_header((header::PRAGMA, "no-cache"))
.insert_header((header::EXPIRES, "0"))
.body(status::WSPR_JS)
}
#[get("/cw.js")]
async fn cw_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::CW_JS)
}
#[get("/bookmarks.js")]
async fn bookmarks_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::BOOKMARKS_JS)
}
async fn send_command(
rig_tx: &mpsc::Sender<RigRequest>,
cmd: RigCommand,
) -> Result<HttpResponse, Error> {
let (resp_tx, resp_rx) = oneshot::channel();
rig_tx
.send(RigRequest {
cmd,
respond_to: resp_tx,
rig_id_override: None,
})
.await
.map_err(|e| {
actix_web::error::ErrorInternalServerError(format!("failed to send to rig: {e:?}"))
})?;
let resp = tokio::time::timeout(REQUEST_TIMEOUT, resp_rx)
.await
.map_err(|_| actix_web::error::ErrorGatewayTimeout("rig response timeout"))?;
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!(
"rig response channel error: {e:?}"
))),
}
}
async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot, actix_web::Error> {
if let Some(view) = rx.borrow().snapshot() {
return Ok(view);
}
// Wait up to 5 seconds for a valid snapshot; fall back to a placeholder
// so the SSE stream starts immediately and the browser isn't left hanging.
let deadline = time::Instant::now() + Duration::from_secs(5);
while let Ok(Ok(())) = time::timeout_at(deadline, rx.changed()).await {
if let Some(view) = rx.borrow().snapshot() {
return Ok(view);
}
}
// Fallback: build a minimal snapshot if rig info is missing.
let state = rx.borrow().clone();
Ok(RigSnapshot {
info: state
.rig_info
.clone()
.unwrap_or_else(|| RigInfoPlaceholder.into()),
status: state.status,
band: None,
enabled: state.control.enabled,
initialized: state.initialized,
server_callsign: state.server_callsign,
server_version: state.server_version,
server_build_date: state.server_build_date,
server_latitude: state.server_latitude,
server_longitude: state.server_longitude,
pskreporter_status: state.pskreporter_status,
aprs_decode_enabled: state.aprs_decode_enabled,
cw_decode_enabled: state.cw_decode_enabled,
cw_auto: state.cw_auto,
cw_wpm: state.cw_wpm,
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(),
spectrum: None,
})
}
struct RigInfoPlaceholder;
impl Default for RigInfoPlaceholder {
fn default() -> Self {
RigInfoPlaceholder
}
}
impl From<RigInfoPlaceholder> for RigInfo {
fn from(_: RigInfoPlaceholder) -> Self {
RigInfo {
manufacturer: "Unknown".to_string(),
model: "Rig".to_string(),
revision: "".to_string(),
capabilities: RigCapabilities {
min_freq_step_hz: 1,
supported_bands: vec![],
supported_modes: vec![],
num_vfos: 0,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
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(),
baud: 0,
},
}
}
}