[refactor](trx-rs): resolve all improvement areas (P1–P3)
P1 — High: - Merge duplicate APRS/HF-APRS decoder tasks into parameterised inner fn - Merge duplicate FT8/FT4 decoder tasks into shared ftx inner fn - Add multi-rig state isolation and command routing tests (listener.rs) - Add background decode evaluate_bookmark unit tests P2 — Medium: - Fix decode-log silent flush errors and rotation failure fallback - Split api.rs (2,831 LOC) into 7 logical modules (decoder, rig, vchan, sse, bookmarks, assets, mod) - Extract background decode decision cascade into pure evaluate_bookmark() function with ChannelAction enum - Relax actix-web pin from =4.4.1 to 4.4 - Replace VDES magic numbers with named constants P3 — Low: - Add doc comments to AisDecoder, VdesDecoder, RdsDecoder - Add debug_assert on turbo decoder interleaver/deinterleaver lengths - Add tracing info_span! to all 10 decoder block_in_place calls - Optimize hot-path string cloning in remote_client spectrum loop https://claude.ai/code/session_01Y3G65hrfsRRjwyBF2qbBmc Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -330,6 +330,8 @@ async fn handle_spectrum_connection(
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
||||
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||
let cached_token = config.token.clone();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -360,7 +362,7 @@ async fn handle_spectrum_connection(
|
||||
Some(short_name.clone())
|
||||
};
|
||||
let envelope = ClientEnvelope {
|
||||
token: config.token.clone(),
|
||||
token: cached_token.clone(),
|
||||
rig_id: wire_rig_id,
|
||||
cmd: ClientCommand::GetSpectrum,
|
||||
protocol_version: Some(trx_protocol::types::PROTOCOL_VERSION),
|
||||
@@ -519,15 +521,10 @@ async fn send_command(
|
||||
if resp.success {
|
||||
if let Some(snapshot) = resp.state {
|
||||
let new_state = RigState::from_snapshot(snapshot.clone());
|
||||
let _ = state_tx.send(new_state.clone());
|
||||
// Also update the per-rig watch channel so SSE sessions
|
||||
// Update the per-rig watch channel first so SSE sessions
|
||||
// subscribed to a specific rig see the change immediately
|
||||
// instead of waiting for the next poll cycle.
|
||||
// The rig_id_override is a short name in multi-server mode;
|
||||
// resolve accordingly for the per-rig channel key.
|
||||
let channel_key = channel_key_override
|
||||
.as_deref()
|
||||
.map(String::from)
|
||||
.or_else(|| selected_rig_id(config));
|
||||
if let Some(key) = channel_key {
|
||||
if let Ok(map) = config.rig_states.read() {
|
||||
@@ -543,6 +540,8 @@ async fn send_command(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Send to main state channel last (takes ownership, no clone).
|
||||
let _ = state_tx.send(new_state);
|
||||
return Ok(snapshot);
|
||||
}
|
||||
return Err(RigError::communication("missing snapshot"));
|
||||
|
||||
@@ -16,7 +16,7 @@ tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
actix-web = "=4.4.1"
|
||||
actix-web = "4.4"
|
||||
actix-ws = "0.3"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Static asset serving endpoints (HTML pages, JS, CSS, favicon, logo).
|
||||
|
||||
use actix_web::{get, HttpRequest, HttpResponse, Responder};
|
||||
use actix_web::http::header;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::server::status;
|
||||
use super::{
|
||||
static_asset_response, GzCacheEntry, gz_cache_entry,
|
||||
FAVICON_BYTES, LOGO_BYTES,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compressed asset caches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! define_gz_cache {
|
||||
($fn_name:ident, $src:expr, $asset_name:literal) => {
|
||||
fn $fn_name() -> &'static GzCacheEntry {
|
||||
static CACHE: OnceLock<GzCacheEntry> = OnceLock::new();
|
||||
CACHE.get_or_init(|| gz_cache_entry($src.as_bytes(), $asset_name))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_gz_cache!(gz_index_html, status::index_html(), "index.html");
|
||||
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
|
||||
define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
|
||||
define_gz_cache!(
|
||||
gz_decode_history_worker_js,
|
||||
status::DECODE_HISTORY_WORKER_JS,
|
||||
"decode-history-worker.js"
|
||||
);
|
||||
define_gz_cache!(
|
||||
gz_webgl_renderer_js,
|
||||
status::WEBGL_RENDERER_JS,
|
||||
"webgl-renderer.js"
|
||||
);
|
||||
define_gz_cache!(
|
||||
gz_leaflet_ais_tracksymbol_js,
|
||||
status::LEAFLET_AIS_TRACKSYMBOL_JS,
|
||||
"leaflet-ais-tracksymbol.js"
|
||||
);
|
||||
define_gz_cache!(gz_ais_js, status::AIS_JS, "ais.js");
|
||||
define_gz_cache!(gz_vdes_js, status::VDES_JS, "vdes.js");
|
||||
define_gz_cache!(gz_aprs_js, status::APRS_JS, "aprs.js");
|
||||
define_gz_cache!(gz_hf_aprs_js, status::HF_APRS_JS, "hf-aprs.js");
|
||||
define_gz_cache!(gz_ft8_js, status::FT8_JS, "ft8.js");
|
||||
define_gz_cache!(gz_ft4_js, status::FT4_JS, "ft4.js");
|
||||
define_gz_cache!(gz_ft2_js, status::FT2_JS, "ft2.js");
|
||||
define_gz_cache!(gz_wspr_js, status::WSPR_JS, "wspr.js");
|
||||
define_gz_cache!(gz_cw_js, status::CW_JS, "cw.js");
|
||||
define_gz_cache!(gz_sat_js, status::SAT_JS, "sat.js");
|
||||
define_gz_cache!(gz_bookmarks_js, status::BOOKMARKS_JS, "bookmarks.js");
|
||||
define_gz_cache!(gz_scheduler_js, status::SCHEDULER_JS, "scheduler.js");
|
||||
define_gz_cache!(
|
||||
gz_sat_scheduler_js,
|
||||
status::SAT_SCHEDULER_JS,
|
||||
"sat-scheduler.js"
|
||||
);
|
||||
define_gz_cache!(
|
||||
gz_background_decode_js,
|
||||
status::BACKGROUND_DECODE_JS,
|
||||
"background-decode.js"
|
||||
);
|
||||
define_gz_cache!(gz_vchan_js, status::VCHAN_JS, "vchan.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML page routes (all serve the SPA index)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/")]
|
||||
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||
}
|
||||
|
||||
#[get("/map")]
|
||||
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||
}
|
||||
|
||||
#[get("/digital-modes")]
|
||||
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||
}
|
||||
|
||||
#[get("/about")]
|
||||
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", &c.gz, &c.etag)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Favicon & logo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/favicon.ico")]
|
||||
pub(crate) async fn favicon() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(FAVICON_BYTES)
|
||||
}
|
||||
|
||||
#[get("/favicon.png")]
|
||||
pub(crate) async fn favicon_png() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(FAVICON_BYTES)
|
||||
}
|
||||
|
||||
#[get("/logo.png")]
|
||||
pub(crate) async fn logo() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(LOGO_BYTES)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/style.css")]
|
||||
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_style_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", &c.gz, &c.etag)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JavaScript assets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/app.js")]
|
||||
pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_app_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/decode-history-worker.js")]
|
||||
pub(crate) async fn decode_history_worker_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_decode_history_worker_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/webgl-renderer.js")]
|
||||
pub(crate) async fn webgl_renderer_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_webgl_renderer_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/leaflet-ais-tracksymbol.js")]
|
||||
pub(crate) async fn leaflet_ais_tracksymbol_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_leaflet_ais_tracksymbol_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/aprs.js")]
|
||||
pub(crate) async fn aprs_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_aprs_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/hf-aprs.js")]
|
||||
pub(crate) async fn hf_aprs_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_hf_aprs_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/ais.js")]
|
||||
pub(crate) async fn ais_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ais_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/vdes.js")]
|
||||
pub(crate) async fn vdes_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_vdes_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/ft8.js")]
|
||||
pub(crate) async fn ft8_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ft8_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/ft4.js")]
|
||||
pub(crate) async fn ft4_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ft4_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/ft2.js")]
|
||||
pub(crate) async fn ft2_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ft2_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/wspr.js")]
|
||||
pub(crate) async fn wspr_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_wspr_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/cw.js")]
|
||||
pub(crate) async fn cw_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_cw_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/sat.js")]
|
||||
pub(crate) async fn sat_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_sat_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/bookmarks.js")]
|
||||
pub(crate) async fn bookmarks_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_bookmarks_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/scheduler.js")]
|
||||
pub(crate) async fn scheduler_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_scheduler_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/sat-scheduler.js")]
|
||||
pub(crate) async fn sat_scheduler_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_sat_scheduler_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/background-decode.js")]
|
||||
pub(crate) async fn background_decode_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_background_decode_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/vchan.js")]
|
||||
pub(crate) async fn vchan_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_vchan_js();
|
||||
static_asset_response(
|
||||
&req,
|
||||
"application/javascript; charset=utf-8",
|
||||
&c.gz,
|
||||
&c.etag,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Bookmark CRUD endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
|
||||
use actix_web::Error;
|
||||
|
||||
use super::{no_cache_response, request_accepts_html, require_control};
|
||||
use crate::server::status;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkQuery {
|
||||
pub category: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkScopeQuery {
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkInput {
|
||||
pub name: String,
|
||||
pub freq_hz: u64,
|
||||
pub mode: String,
|
||||
pub bandwidth_hz: Option<u64>,
|
||||
pub locator: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub decoders: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// A bookmark with its owning scope tag for the list response.
|
||||
#[derive(serde::Serialize)]
|
||||
struct BookmarkWithScope {
|
||||
#[serde(flatten)]
|
||||
bm: crate::server::bookmarks::Bookmark,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchDeleteRequest {
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchMoveRequest {
|
||||
ids: Vec<String>,
|
||||
to: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Resolve which `BookmarkStore` to use based on the `scope` parameter.
|
||||
fn resolve_bookmark_store(
|
||||
scope: Option<&str>,
|
||||
store_map: &crate::server::bookmarks::BookmarkStoreMap,
|
||||
) -> std::sync::Arc<crate::server::bookmarks::BookmarkStore> {
|
||||
match scope.filter(|s| !s.is_empty() && *s != "general") {
|
||||
Some(remote) => store_map.store_for(remote),
|
||||
None => store_map.general().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_bookmark_id() -> String {
|
||||
hex::encode(rand::random::<[u8; 16]>())
|
||||
}
|
||||
|
||||
fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
|
||||
locator.and_then(|value| {
|
||||
let trimmed = value.trim().to_uppercase();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[get("/bookmarks")]
|
||||
pub async fn list_bookmarks(
|
||||
req: HttpRequest,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkQuery>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
if request_accepts_html(&req) {
|
||||
return Ok(no_cache_response(
|
||||
"text/html; charset=utf-8",
|
||||
status::index_html(),
|
||||
));
|
||||
}
|
||||
let scope = query
|
||||
.scope
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "general");
|
||||
let mut list: Vec<BookmarkWithScope> = match scope {
|
||||
Some(remote) => {
|
||||
let mut map: std::collections::HashMap<String, BookmarkWithScope> = store_map
|
||||
.general()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|bm| {
|
||||
let id = bm.id.clone();
|
||||
(
|
||||
id,
|
||||
BookmarkWithScope {
|
||||
bm,
|
||||
scope: "general".into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for bm in store_map.store_for(remote).list() {
|
||||
let id = bm.id.clone();
|
||||
map.insert(
|
||||
id,
|
||||
BookmarkWithScope {
|
||||
bm,
|
||||
scope: remote.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
map.into_values().collect()
|
||||
}
|
||||
None => store_map
|
||||
.general()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|bm| BookmarkWithScope {
|
||||
bm,
|
||||
scope: "general".into(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
if let Some(ref cat) = query.category {
|
||||
if !cat.is_empty() {
|
||||
let cat_lower = cat.to_lowercase();
|
||||
list.retain(|item| item.bm.category.to_lowercase() == cat_lower);
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|item| item.bm.freq_hz);
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
#[post("/bookmarks")]
|
||||
pub async fn create_bookmark(
|
||||
req: HttpRequest,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
if store.freq_taken(body.freq_hz, None) {
|
||||
return Err(actix_web::error::ErrorConflict(
|
||||
"a bookmark for that frequency already exists",
|
||||
));
|
||||
}
|
||||
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,
|
||||
locator: normalize_bookmark_locator(body.locator.clone()),
|
||||
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_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let id = path.into_inner();
|
||||
if store.freq_taken(body.freq_hz, Some(&id)) {
|
||||
return Err(actix_web::error::ErrorConflict(
|
||||
"a bookmark for that frequency already exists",
|
||||
));
|
||||
}
|
||||
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,
|
||||
locator: normalize_bookmark_locator(body.locator.clone()),
|
||||
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_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/bookmarks/batch_delete")]
|
||||
pub async fn batch_delete_bookmarks(
|
||||
req: HttpRequest,
|
||||
body: web::Json<BatchDeleteRequest>,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let mut deleted = 0usize;
|
||||
for id in &body.ids {
|
||||
if store.remove(id) {
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
|
||||
}
|
||||
|
||||
#[post("/bookmarks/batch_move")]
|
||||
pub async fn batch_move_bookmarks(
|
||||
req: HttpRequest,
|
||||
body: web::Json<BatchMoveRequest>,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
|
||||
let mut moved = 0usize;
|
||||
for id in &body.ids {
|
||||
if let Some(bm) = from_store.get(id) {
|
||||
if to_store.insert(&bm) && from_store.remove(id) {
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "moved": moved })))
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Decoder toggle/clear endpoints and decode history.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
||||
use actix_web::Error;
|
||||
use actix_web::http::header;
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{select, StreamExt};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::time::{self, Duration};
|
||||
use tokio_stream::wrappers::IntervalStream;
|
||||
|
||||
use trx_core::{RigCommand, RigRequest, RigState};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
use super::{gzip_bytes, send_command, RemoteQuery};
|
||||
|
||||
// ============================================================================
|
||||
// Decode history types and helpers
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DecodeHistoryPayload {
|
||||
ais: Vec<trx_core::decode::AisMessage>,
|
||||
vdes: Vec<trx_core::decode::VdesMessage>,
|
||||
aprs: Vec<trx_core::decode::AprsPacket>,
|
||||
hf_aprs: Vec<trx_core::decode::AprsPacket>,
|
||||
cw: Vec<trx_core::decode::CwEvent>,
|
||||
ft8: Vec<trx_core::decode::Ft8Message>,
|
||||
ft4: Vec<trx_core::decode::Ft8Message>,
|
||||
ft2: Vec<trx_core::decode::Ft8Message>,
|
||||
wspr: Vec<trx_core::decode::WsprMessage>,
|
||||
}
|
||||
|
||||
impl DecodeHistoryPayload {
|
||||
fn total_messages(&self) -> usize {
|
||||
self.ais.len()
|
||||
+ self.vdes.len()
|
||||
+ self.aprs.len()
|
||||
+ self.hf_aprs.len()
|
||||
+ self.cw.len()
|
||||
+ self.ft8.len()
|
||||
+ self.ft4.len()
|
||||
+ self.ft2.len()
|
||||
+ self.wspr.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the grouped decode history payload from all per-decoder ring-buffers.
|
||||
fn collect_decode_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> DecodeHistoryPayload {
|
||||
DecodeHistoryPayload {
|
||||
ais: crate::server::audio::snapshot_ais_history(context, rig_filter),
|
||||
vdes: crate::server::audio::snapshot_vdes_history(context, rig_filter),
|
||||
aprs: crate::server::audio::snapshot_aprs_history(context, rig_filter),
|
||||
hf_aprs: crate::server::audio::snapshot_hf_aprs_history(context, rig_filter),
|
||||
cw: crate::server::audio::snapshot_cw_history(context, rig_filter),
|
||||
ft8: crate::server::audio::snapshot_ft8_history(context, rig_filter),
|
||||
ft4: crate::server::audio::snapshot_ft4_history(context, rig_filter),
|
||||
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
||||
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cbor_length(out: &mut Vec<u8>, major: u8, value: u64) {
|
||||
debug_assert!(major <= 7);
|
||||
match value {
|
||||
0..=23 => out.push((major << 5) | (value as u8)),
|
||||
24..=0xff => {
|
||||
out.push((major << 5) | 24);
|
||||
out.push(value as u8);
|
||||
}
|
||||
0x100..=0xffff => {
|
||||
out.push((major << 5) | 25);
|
||||
out.extend_from_slice(&(value as u16).to_be_bytes());
|
||||
}
|
||||
0x1_0000..=0xffff_ffff => {
|
||||
out.push((major << 5) | 26);
|
||||
out.extend_from_slice(&(value as u32).to_be_bytes());
|
||||
}
|
||||
_ => {
|
||||
out.push((major << 5) | 27);
|
||||
out.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cbor_json_value(out: &mut Vec<u8>, value: &serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Null => out.push(0xf6),
|
||||
serde_json::Value::Bool(false) => out.push(0xf4),
|
||||
serde_json::Value::Bool(true) => out.push(0xf5),
|
||||
serde_json::Value::Number(number) => {
|
||||
if let Some(value) = number.as_u64() {
|
||||
encode_cbor_length(out, 0, value);
|
||||
} else if let Some(value) = number.as_i64() {
|
||||
if value >= 0 {
|
||||
encode_cbor_length(out, 0, value as u64);
|
||||
} else {
|
||||
encode_cbor_length(out, 1, value.unsigned_abs() - 1);
|
||||
}
|
||||
} else if let Some(value) = number.as_f64() {
|
||||
out.push(0xfb);
|
||||
out.extend_from_slice(&value.to_be_bytes());
|
||||
} else {
|
||||
out.push(0xf6);
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(text) => {
|
||||
encode_cbor_length(out, 3, text.len() as u64);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
encode_cbor_length(out, 4, items.len() as u64);
|
||||
for item in items {
|
||||
encode_cbor_json_value(out, item);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
encode_cbor_length(out, 5, map.len() as u64);
|
||||
for (key, item) in map {
|
||||
encode_cbor_length(out, 3, key.len() as u64);
|
||||
out.extend_from_slice(key.as_bytes());
|
||||
encode_cbor_json_value(out, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_decode_history_cbor(
|
||||
history: &DecodeHistoryPayload,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let value = serde_json::to_value(history)?;
|
||||
let mut out = Vec::with_capacity(history.total_messages().saturating_mul(96));
|
||||
encode_cbor_json_value(&mut out, &value);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decode history endpoint
|
||||
// ============================================================================
|
||||
|
||||
/// `GET /decode/history` — returns the full decode history as gzipped CBOR.
|
||||
#[get("/decode/history")]
|
||||
pub async fn decode_history(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
query: web::Query<RemoteQuery>,
|
||||
) -> impl Responder {
|
||||
if context.audio.decode_rx.is_none() {
|
||||
return HttpResponse::NotFound().body("decode not enabled");
|
||||
}
|
||||
let rig_filter = query.remote.as_deref().filter(|s| !s.is_empty());
|
||||
let history = collect_decode_history(context.get_ref(), rig_filter);
|
||||
let cbor = match encode_decode_history_cbor(&history) {
|
||||
Ok(cbor) => cbor,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to encode decode history as CBOR: {err}");
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
let payload = match gzip_bytes(&cbor) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to gzip decode history payload: {err}");
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "application/cbor"))
|
||||
.insert_header((header::CONTENT_ENCODING, "gzip"))
|
||||
.body(payload)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decode SSE 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 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 = select(pings, decode_stream);
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoder toggle endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[post("/toggle_aprs_decode")]
|
||||
pub async fn toggle_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.aprs_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetAprsDecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_hf_aprs_decode")]
|
||||
pub async fn toggle_hf_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.hf_aprs_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetHfAprsDecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_cw_decode")]
|
||||
pub async fn toggle_cw_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.cw_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetCwDecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CwAutoQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_cw_auto")]
|
||||
pub async fn set_cw_auto(
|
||||
query: web::Query<CwAutoQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetCwAuto(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CwWpmQuery {
|
||||
pub wpm: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_cw_wpm")]
|
||||
pub async fn set_cw_wpm(
|
||||
query: web::Query<CwWpmQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetCwWpm(q.wpm), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CwToneQuery {
|
||||
pub tone_hz: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_cw_tone")]
|
||||
pub async fn set_cw_tone(
|
||||
query: web::Query<CwToneQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetCwToneHz(q.tone_hz), q.remote).await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft8_decode")]
|
||||
pub async fn toggle_ft8_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.ft8_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetFt8DecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft4_decode")]
|
||||
pub async fn toggle_ft4_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.ft4_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetFt4DecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft2_decode")]
|
||||
pub async fn toggle_ft2_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.ft2_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetFt2DecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_wspr_decode")]
|
||||
pub async fn toggle_wspr_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.wspr_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetWsprDecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_lrpt_decode")]
|
||||
pub async fn toggle_lrpt_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let enabled = state.get_ref().borrow().decoders.lrpt_decode_enabled;
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetLrptDecodeEnabled(!enabled),
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoder clear endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[post("/clear_lrpt_decode")]
|
||||
pub async fn clear_lrpt_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetLrptDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ft8_decode")]
|
||||
pub async fn clear_ft8_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
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,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ft4_decode")]
|
||||
pub async fn clear_ft4_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ft4_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetFt4Decoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ft2_decode")]
|
||||
pub async fn clear_ft2_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ft2_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetFt2Decoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_wspr_decode")]
|
||||
pub async fn clear_wspr_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
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,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_aprs_decode")]
|
||||
pub async fn clear_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
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,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_hf_aprs_decode")]
|
||||
pub async fn clear_hf_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_hf_aprs_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetHfAprsDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ais_decode")]
|
||||
pub async fn clear_ais_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ais_history(context.get_ref());
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[post("/clear_vdes_decode")]
|
||||
pub async fn clear_vdes_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_vdes_history(context.get_ref());
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[post("/clear_cw_decode")]
|
||||
pub async fn clear_cw_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
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,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,535 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Rig control endpoints: status, frequency, mode, PTT, SDR settings, etc.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
||||
use actix_web::{http::header, Error};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::state::WfmDenoiseLevel;
|
||||
use trx_core::{RigCommand, RigRequest, RigState};
|
||||
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||
use trx_protocol::parse_mode;
|
||||
|
||||
use crate::server::vchan::ClientChannelManager;
|
||||
|
||||
use super::{
|
||||
active_rig_id_from_context, frontend_meta_from_context, send_command, wait_for_view,
|
||||
RemoteQuery, SessionRigManager, SnapshotWithMeta, StatusQuery,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Status
|
||||
// ============================================================================
|
||||
|
||||
#[get("/status")]
|
||||
pub async fn status_api(
|
||||
query: web::Query<StatusQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
clients: web::Data<Arc<AtomicUsize>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
let rx = query
|
||||
.remote
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|rid| context.rig_state_rx(rid))
|
||||
.unwrap_or_else(|| state.get_ref().clone());
|
||||
let snapshot = wait_for_view(rx).await?;
|
||||
let combined = SnapshotWithMeta {
|
||||
snapshot: &snapshot,
|
||||
meta: frontend_meta_from_context(
|
||||
clients.load(Ordering::Relaxed),
|
||||
context.get_ref().as_ref(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let json =
|
||||
serde_json::to_string(&combined).map_err(actix_web::error::ErrorInternalServerError)?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "application/json"))
|
||||
.body(json))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Power / VFO / Lock
|
||||
// ============================================================================
|
||||
|
||||
#[post("/toggle_power")]
|
||||
pub async fn toggle_power(
|
||||
query: web::Query<RemoteQuery>,
|
||||
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, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
#[post("/toggle_vfo")]
|
||||
pub async fn toggle_vfo(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::ToggleVfo, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
#[post("/lock")]
|
||||
pub async fn lock_panel(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::Lock, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
#[post("/unlock")]
|
||||
pub async fn unlock_panel(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::Unlock, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frequency / Mode / PTT
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FreqQuery {
|
||||
pub hz: u64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_freq")]
|
||||
pub async fn set_freq(
|
||||
query: web::Query<FreqQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: q.hz }), q.remote).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> {
|
||||
let q = query.into_inner();
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetCenterFreq(Freq { hz: q.hz }),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ModeQuery {
|
||||
pub mode: String,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_mode")]
|
||||
pub async fn set_mode(
|
||||
query: web::Query<ModeQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let mode = parse_mode(&q.mode);
|
||||
send_command(&rig_tx, RigCommand::SetMode(mode), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PttQuery {
|
||||
pub ptt: String,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_ptt")]
|
||||
pub async fn set_ptt(
|
||||
query: web::Query<PttQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let ptt = match q.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), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TxLimitQuery {
|
||||
pub limit: u8,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_tx_limit")]
|
||||
pub async fn set_tx_limit(
|
||||
query: web::Query<TxLimitQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetTxLimit(q.limit), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BandwidthQuery {
|
||||
pub hz: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_bandwidth")]
|
||||
pub async fn set_bandwidth(
|
||||
query: web::Query<BandwidthQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetBandwidth(q.hz), q.remote).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SDR settings
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrGainQuery {
|
||||
pub db: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_gain")]
|
||||
pub async fn set_sdr_gain(
|
||||
query: web::Query<SdrGainQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSdrGain(q.db), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrLnaGainQuery {
|
||||
pub db: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_lna_gain")]
|
||||
pub async fn set_sdr_lna_gain(
|
||||
query: web::Query<SdrLnaGainQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSdrLnaGain(q.db), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrAgcQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_agc")]
|
||||
pub async fn set_sdr_agc(
|
||||
query: web::Query<SdrAgcQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSdrAgc(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrSquelchQuery {
|
||||
pub enabled: bool,
|
||||
pub threshold_db: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_squelch")]
|
||||
pub async fn set_sdr_squelch(
|
||||
query: web::Query<SdrSquelchQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetSdrSquelch {
|
||||
enabled: q.enabled,
|
||||
threshold_db: q.threshold_db,
|
||||
},
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrNoiseBlankerQuery {
|
||||
pub enabled: bool,
|
||||
pub threshold: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_noise_blanker")]
|
||||
pub async fn set_sdr_noise_blanker(
|
||||
query: web::Query<SdrNoiseBlankerQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetSdrNoiseBlanker {
|
||||
enabled: q.enabled,
|
||||
threshold: q.threshold,
|
||||
},
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WFM / SAM settings
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WfmDeemphasisQuery {
|
||||
pub us: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_wfm_deemphasis")]
|
||||
pub async fn set_wfm_deemphasis(
|
||||
query: web::Query<WfmDeemphasisQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetWfmDeemphasis(q.us), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WfmStereoQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_wfm_stereo")]
|
||||
pub async fn set_wfm_stereo(
|
||||
query: web::Query<WfmStereoQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetWfmStereo(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WfmDenoiseQuery {
|
||||
pub level: WfmDenoiseLevel,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_wfm_denoise")]
|
||||
pub async fn set_wfm_denoise(
|
||||
query: web::Query<WfmDenoiseQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetWfmDenoise(q.level), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SamStereoWidthQuery {
|
||||
pub width: f32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sam_stereo_width")]
|
||||
pub async fn set_sam_stereo_width(
|
||||
query: web::Query<SamStereoWidthQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSamStereoWidth(q.width), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SamCarrierSyncQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sam_carrier_sync")]
|
||||
pub async fn set_sam_carrier_sync(
|
||||
query: web::Query<SamCarrierSyncQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSamCarrierSync(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rig list / selection
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListItem {
|
||||
remote: String,
|
||||
display_name: Option<String>,
|
||||
manufacturer: String,
|
||||
model: String,
|
||||
initialized: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
latitude: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
longitude: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListResponse {
|
||||
active_remote: Option<String>,
|
||||
rigs: Vec<RigListItem>,
|
||||
}
|
||||
|
||||
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
|
||||
let active_remote = active_rig_id_from_context(context);
|
||||
let rigs = context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().map(map_rig_entry).collect())
|
||||
.unwrap_or_default();
|
||||
RigListResponse {
|
||||
active_remote,
|
||||
rigs,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
|
||||
RigListItem {
|
||||
remote: 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,
|
||||
latitude: entry.state.server_latitude,
|
||||
longitude: entry.state.server_longitude,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 remote: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/select_rig")]
|
||||
pub async fn select_rig(
|
||||
query: web::Query<SelectRigQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
session_rig_mgr: web::Data<Arc<SessionRigManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let remote = query.remote.trim();
|
||||
if remote.is_empty() {
|
||||
return Err(actix_web::error::ErrorBadRequest(
|
||||
"remote must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let known = context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().any(|entry| entry.rig_id == remote))
|
||||
.unwrap_or(false);
|
||||
if !known {
|
||||
return Err(actix_web::error::ErrorBadRequest(format!(
|
||||
"unknown remote: {remote}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Only update per-session rig selection — never mutate the global
|
||||
// active rig so that other tabs/sessions are unaffected.
|
||||
if let Some(ref sid) = query.session_id {
|
||||
if let Ok(uuid) = Uuid::parse_str(sid) {
|
||||
session_rig_mgr.set_rig(uuid, remote.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast the channel list for the newly selected rig so all SSE
|
||||
// clients receive the correct virtual channels immediately.
|
||||
let chans = vchan_mgr.channels(remote);
|
||||
if let Ok(json) = serde_json::to_string(&chans) {
|
||||
let _ = vchan_mgr.change_tx.send(format!("{remote}:{json}"));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Satellite passes
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SatPassesResponse {
|
||||
passes: Vec<trx_core::geo::PassPrediction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
/// Number of satellites evaluated for predictions.
|
||||
satellite_count: usize,
|
||||
/// Source of the TLE data used: "celestrak" or "unavailable".
|
||||
tle_source: trx_core::geo::TleSource,
|
||||
}
|
||||
|
||||
/// Return predicted passes for all known satellites over the next 24 h.
|
||||
#[get("/sat_passes")]
|
||||
pub async fn sat_passes(context: web::Data<Arc<FrontendRuntimeContext>>) -> impl Responder {
|
||||
let cached = context
|
||||
.routing
|
||||
.sat_passes
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.clone());
|
||||
match cached {
|
||||
Some(result) => {
|
||||
let error = match result.tle_source {
|
||||
trx_core::geo::TleSource::Unavailable => {
|
||||
Some("TLE data not yet available — waiting for CelesTrak fetch".to_string())
|
||||
}
|
||||
trx_core::geo::TleSource::Celestrak => None,
|
||||
};
|
||||
web::Json(SatPassesResponse {
|
||||
passes: result.passes,
|
||||
error,
|
||||
satellite_count: result.satellite_count,
|
||||
tle_source: result.tle_source,
|
||||
})
|
||||
}
|
||||
None => web::Json(SatPassesResponse {
|
||||
passes: vec![],
|
||||
error: Some("Satellite predictions not yet available from server".to_string()),
|
||||
satellite_count: 0,
|
||||
tle_source: trx_core::geo::TleSource::Unavailable,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! SSE stream endpoints: /events (rig state) and /spectrum.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use actix_web::Error;
|
||||
use actix_web::http::header;
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{select, StreamExt};
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio::time::{self, Duration};
|
||||
use tokio_stream::wrappers::{IntervalStream, WatchStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::RigState;
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
use crate::server::vchan::ClientChannelManager;
|
||||
|
||||
use super::{
|
||||
base64_encode, frontend_meta_from_context, wait_for_view,
|
||||
RemoteQuery, SessionRigManager, SnapshotWithMeta,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// DropStream utility
|
||||
// ============================================================================
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Spectrum encoding
|
||||
// ============================================================================
|
||||
|
||||
/// Encode spectrum bins as a compact base64 string of i8 values (1 dB/step).
|
||||
fn encode_spectrum_frame(frame: &trx_core::rig::state::SpectrumData) -> String {
|
||||
let clamped: Vec<u8> = frame
|
||||
.bins
|
||||
.iter()
|
||||
.map(|&v| v.round().clamp(-128.0, 127.0) as i8 as u8)
|
||||
.collect();
|
||||
let b64 = base64_encode(&clamped);
|
||||
|
||||
let mut out = String::with_capacity(40 + b64.len());
|
||||
out.push_str(&frame.center_hz.to_string());
|
||||
out.push(',');
|
||||
out.push_str(&frame.sample_rate.to_string());
|
||||
out.push(',');
|
||||
out.push_str(&b64);
|
||||
out
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scheduler vchannel sync helper
|
||||
// ============================================================================
|
||||
|
||||
fn sync_scheduler_vchannels(
|
||||
vchan_mgr: &ClientChannelManager,
|
||||
bookmark_store_map: &crate::server::bookmarks::BookmarkStoreMap,
|
||||
scheduler_status: &crate::server::scheduler::SchedulerStatusMap,
|
||||
scheduler_control: &crate::server::scheduler::SchedulerControlManager,
|
||||
rig_id: &str,
|
||||
) {
|
||||
if !scheduler_control.scheduler_allowed() {
|
||||
vchan_mgr.sync_scheduler_channels(rig_id, &[]);
|
||||
return;
|
||||
}
|
||||
|
||||
let desired = {
|
||||
let map = scheduler_status.read().unwrap_or_else(|e| e.into_inner());
|
||||
map.get(rig_id)
|
||||
.filter(|status| status.active)
|
||||
.map(|status| {
|
||||
status
|
||||
.last_bookmark_ids
|
||||
.iter()
|
||||
.filter_map(|bookmark_id| {
|
||||
bookmark_store_map
|
||||
.get_for_rig(rig_id, bookmark_id)
|
||||
.map(|bookmark| {
|
||||
(
|
||||
bookmark_id.clone(),
|
||||
bookmark.freq_hz,
|
||||
bookmark.mode.clone(),
|
||||
bookmark.bandwidth_hz.unwrap_or(0) as u32,
|
||||
bookmark_decoder_kinds(&bookmark),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
vchan_mgr.sync_scheduler_channels(rig_id, &desired);
|
||||
}
|
||||
|
||||
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for decoder in bookmark
|
||||
.decoders
|
||||
.iter()
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
{
|
||||
if matches!(
|
||||
decoder.as_str(),
|
||||
"aprs" | "ais" | "ft8" | "ft4" | "ft2" | "wspr" | "hf-aprs"
|
||||
) && !out.iter().any(|existing| existing == &decoder)
|
||||
{
|
||||
out.push(decoder);
|
||||
}
|
||||
}
|
||||
|
||||
if !out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
match bookmark.mode.trim().to_ascii_uppercase().as_str() {
|
||||
"AIS" => vec!["ais".to_string()],
|
||||
"PKT" => vec!["aprs".to_string()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /events SSE endpoint
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct EventsQuery {
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/events")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn events(
|
||||
query: web::Query<EventsQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
clients: web::Data<Arc<AtomicUsize>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
bookmark_store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
scheduler_status: web::Data<crate::server::scheduler::SchedulerStatusMap>,
|
||||
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
|
||||
session_rig_mgr: web::Data<Arc<SessionRigManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let counter = clients.get_ref().clone();
|
||||
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
// Assign a stable UUID to this SSE session for channel binding.
|
||||
let session_id = Uuid::new_v4();
|
||||
scheduler_control.register_session(session_id);
|
||||
|
||||
// Use the client-requested remote if provided, otherwise fall back to
|
||||
// the global default.
|
||||
let active_rig_id = query.remote.clone().filter(|s| !s.is_empty()).or_else(|| {
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
});
|
||||
|
||||
// Subscribe to the per-rig watch channel for this session's rig.
|
||||
let rx = active_rig_id
|
||||
.as_deref()
|
||||
.and_then(|rid| context.rig_state_rx(rid))
|
||||
.unwrap_or_else(|| state.get_ref().clone());
|
||||
let initial = wait_for_view(rx.clone()).await?;
|
||||
if let Some(ref rid) = active_rig_id {
|
||||
session_rig_mgr.register(session_id, rid.clone());
|
||||
vchan_mgr.init_rig(
|
||||
rid,
|
||||
initial.status.freq.hz,
|
||||
&format!("{:?}", initial.status.mode),
|
||||
);
|
||||
sync_scheduler_vchannels(
|
||||
vchan_mgr.get_ref().as_ref(),
|
||||
bookmark_store_map.get_ref().as_ref(),
|
||||
scheduler_status.get_ref(),
|
||||
scheduler_control.get_ref().as_ref(),
|
||||
rid,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the prefix burst: rig state → session UUID → initial channels.
|
||||
let initial_combined = SnapshotWithMeta {
|
||||
snapshot: &initial,
|
||||
meta: frontend_meta_from_context(
|
||||
count,
|
||||
context.get_ref().as_ref(),
|
||||
active_rig_id.as_deref(),
|
||||
),
|
||||
};
|
||||
let initial_json = serde_json::to_string(&initial_combined)
|
||||
.map_err(actix_web::error::ErrorInternalServerError)?;
|
||||
|
||||
let mut prefix: Vec<Result<Bytes, Error>> = Vec::new();
|
||||
prefix.push(Ok(Bytes::from(format!("data: {initial_json}\n\n"))));
|
||||
prefix.push(Ok(Bytes::from(format!(
|
||||
"event: session\ndata: {{\"session_id\":\"{session_id}\"}}\n\n"
|
||||
))));
|
||||
if let Some(ref rid) = active_rig_id {
|
||||
let chans = vchan_mgr.channels(rid);
|
||||
if let Ok(json) = serde_json::to_string(&chans) {
|
||||
prefix.push(Ok(Bytes::from(format!(
|
||||
"event: channels\ndata: {{\"remote\":\"{rid}\",\"channels\":{json}}}\n\n"
|
||||
))));
|
||||
}
|
||||
}
|
||||
let prefix_stream = futures_util::stream::iter(prefix);
|
||||
|
||||
// Live rig-state updates; side-effect: keep primary channel metadata in sync.
|
||||
let counter_updates = counter.clone();
|
||||
let context_updates = context.get_ref().clone();
|
||||
let vchan_updates = vchan_mgr.get_ref().clone();
|
||||
let bookmark_store_map_updates = bookmark_store_map.get_ref().clone();
|
||||
let scheduler_status_updates = scheduler_status.get_ref().clone();
|
||||
let scheduler_control_updates = scheduler_control.get_ref().clone();
|
||||
let session_rig_mgr_updates = session_rig_mgr.get_ref().clone();
|
||||
let updates = WatchStream::new(rx).filter_map(move |state| {
|
||||
let counter = counter_updates.clone();
|
||||
let context = context_updates.clone();
|
||||
let vchan = vchan_updates.clone();
|
||||
let bookmark_store_map = bookmark_store_map_updates.clone();
|
||||
let scheduler_status = scheduler_status_updates.clone();
|
||||
let scheduler_control = scheduler_control_updates.clone();
|
||||
let session_rig_mgr = session_rig_mgr_updates.clone();
|
||||
async move {
|
||||
state.snapshot().and_then(|v| {
|
||||
let rig_id_opt = session_rig_mgr.get_rig(session_id).or_else(|| {
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
});
|
||||
if let Some(ref rig_id) = rig_id_opt {
|
||||
vchan.update_primary(rig_id, v.status.freq.hz, &format!("{:?}", v.status.mode));
|
||||
sync_scheduler_vchannels(
|
||||
vchan.as_ref(),
|
||||
bookmark_store_map.as_ref(),
|
||||
&scheduler_status,
|
||||
scheduler_control.as_ref(),
|
||||
rig_id,
|
||||
);
|
||||
}
|
||||
let combined = SnapshotWithMeta {
|
||||
snapshot: &v,
|
||||
meta: frontend_meta_from_context(
|
||||
counter.load(Ordering::Relaxed),
|
||||
context.as_ref(),
|
||||
rig_id_opt.as_deref(),
|
||||
),
|
||||
};
|
||||
serde_json::to_string(&combined)
|
||||
.ok()
|
||||
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Channel-list change events from the virtual channel manager.
|
||||
let vchan_change_rx = vchan_mgr.change_tx.subscribe();
|
||||
let session_rig_for_chan = active_rig_id.clone();
|
||||
let chan_updates = futures_util::stream::unfold(
|
||||
(vchan_change_rx, session_rig_for_chan),
|
||||
|(mut rx, srig)| async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => {
|
||||
if let Some(colon) = msg.find(':') {
|
||||
let rig_id = &msg[..colon];
|
||||
if let Some(ref expected) = srig {
|
||||
if rig_id != expected.as_str() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let channels_json = &msg[colon + 1..];
|
||||
let payload =
|
||||
format!("{{\"remote\":\"{rig_id}\",\"channels\":{channels_json}}}");
|
||||
return Some((
|
||||
Ok::<Bytes, Error>(Bytes::from(format!(
|
||||
"event: channels\ndata: {payload}\n\n"
|
||||
))),
|
||||
(rx, srig),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Send a named "ping" event so the JS heartbeat can observe it.
|
||||
let pings = IntervalStream::new(time::interval(Duration::from_secs(5)))
|
||||
.map(|_| Ok::<Bytes, Error>(Bytes::from("event: ping\ndata: \n\n")));
|
||||
|
||||
let vchan_drop = vchan_mgr.get_ref().clone();
|
||||
let counter_drop = counter.clone();
|
||||
let scheduler_control_drop = scheduler_control.get_ref().clone();
|
||||
let session_rig_mgr_drop = session_rig_mgr.get_ref().clone();
|
||||
let live = select(select(pings, updates), chan_updates);
|
||||
let stream = prefix_stream.chain(live);
|
||||
let stream = DropStream::new(Box::pin(stream), move || {
|
||||
counter_drop.fetch_sub(1, Ordering::Relaxed);
|
||||
vchan_drop.release_session(session_id);
|
||||
scheduler_control_drop.unregister_session(session_id);
|
||||
session_rig_mgr_drop.unregister(session_id);
|
||||
});
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /spectrum SSE endpoint
|
||||
// ============================================================================
|
||||
|
||||
/// SSE stream for spectrum data.
|
||||
#[get("/spectrum")]
|
||||
pub async fn spectrum(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let rx = if let Some(ref remote) = query.remote {
|
||||
context.rig_spectrum_rx(remote)
|
||||
} else {
|
||||
context.spectrum.sender.subscribe()
|
||||
};
|
||||
let mut last_rds_json: Option<String> = None;
|
||||
let mut last_vchan_rds_json: Option<String> = None;
|
||||
let mut last_had_frame = false;
|
||||
let updates = WatchStream::new(rx).filter_map(move |snapshot| {
|
||||
let sse_chunk: Option<String> = if let Some(ref frame) = snapshot.frame {
|
||||
last_had_frame = true;
|
||||
let mut chunk = format!("event: b\ndata: {}\n\n", encode_spectrum_frame(frame));
|
||||
if snapshot.rds_json != last_rds_json {
|
||||
let data = snapshot.rds_json.as_deref().unwrap_or("null");
|
||||
chunk.push_str(&format!("event: rds\ndata: {data}\n\n"));
|
||||
last_rds_json = snapshot.rds_json;
|
||||
}
|
||||
if snapshot.vchan_rds_json != last_vchan_rds_json {
|
||||
let data = snapshot.vchan_rds_json.as_deref().unwrap_or("null");
|
||||
chunk.push_str(&format!("event: rds_vchan\ndata: {data}\n\n"));
|
||||
last_vchan_rds_json = snapshot.vchan_rds_json;
|
||||
}
|
||||
Some(chunk)
|
||||
} else if last_had_frame {
|
||||
last_had_frame = false;
|
||||
Some("data: null\n\n".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
std::future::ready(sse_chunk.map(|s| Ok::<Bytes, Error>(Bytes::from(s))))
|
||||
});
|
||||
|
||||
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::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Virtual channel management endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
|
||||
use actix_web::Error;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::{RigCommand, RigRequest};
|
||||
use trx_protocol::parse_mode;
|
||||
|
||||
use crate::server::vchan::ClientChannelManager;
|
||||
|
||||
use super::send_command_to_rig;
|
||||
|
||||
// ============================================================================
|
||||
// Channel CRUD
|
||||
// ============================================================================
|
||||
|
||||
#[get("/channels/{remote}")]
|
||||
pub async fn list_channels(
|
||||
path: web::Path<String>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let remote = path.into_inner();
|
||||
HttpResponse::Ok().json(vchan_mgr.channels(&remote))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AllocateChannelBody {
|
||||
session_id: Uuid,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[post("/channels/{remote}")]
|
||||
pub async fn allocate_channel(
|
||||
path: web::Path<String>,
|
||||
body: web::Json<AllocateChannelBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let remote = path.into_inner();
|
||||
match vchan_mgr.allocate(body.session_id, &remote, body.freq_hz, &body.mode) {
|
||||
Ok(ch) => HttpResponse::Ok().json(ch),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/channels/{remote}/{channel_id}")]
|
||||
pub async fn delete_channel_route(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.delete_channel(&remote, channel_id) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::Permanent) => {
|
||||
HttpResponse::BadRequest().body("cannot remove the primary channel")
|
||||
}
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SubscribeBody {
|
||||
session_id: Uuid,
|
||||
}
|
||||
|
||||
#[post("/channels/{remote}/{channel_id}/subscribe")]
|
||||
pub async fn subscribe_channel(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SubscribeBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
bookmark_store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
|
||||
) -> impl Responder {
|
||||
let body = body.into_inner();
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.subscribe_session(body.session_id, &remote, channel_id) {
|
||||
Some(ch) => {
|
||||
scheduler_control.set_released(body.session_id, false);
|
||||
let Some(selected) = vchan_mgr.selected_channel(&remote, channel_id) else {
|
||||
return HttpResponse::InternalServerError().body("subscribed channel missing");
|
||||
};
|
||||
if let Err(err) = apply_selected_channel(
|
||||
rig_tx.get_ref(),
|
||||
&remote,
|
||||
&selected,
|
||||
bookmark_store_map.get_ref().as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return HttpResponse::from_error(err);
|
||||
}
|
||||
HttpResponse::Ok().json(ch)
|
||||
}
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel property updates
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetChanFreqBody {
|
||||
freq_hz: u64,
|
||||
}
|
||||
|
||||
#[put("/channels/{remote}/{channel_id}/freq")]
|
||||
pub async fn set_vchan_freq(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SetChanFreqBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.set_channel_freq(&remote, channel_id, body.freq_hz) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetChanBwBody {
|
||||
bandwidth_hz: u32,
|
||||
}
|
||||
|
||||
#[put("/channels/{remote}/{channel_id}/bw")]
|
||||
pub async fn set_vchan_bw(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SetChanBwBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.set_channel_bandwidth(&remote, channel_id, body.bandwidth_hz) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetChanModeBody {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[put("/channels/{remote}/{channel_id}/mode")]
|
||||
pub async fn set_vchan_mode(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SetChanModeBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.set_channel_mode(&remote, channel_id, &body.mode) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn bookmark_decoder_state(
|
||||
bookmark: &crate::server::bookmarks::Bookmark,
|
||||
) -> (bool, bool, bool, bool, bool, bool, bool) {
|
||||
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
|
||||
let mut want_hf_aprs = false;
|
||||
let mut want_ft8 = false;
|
||||
let mut want_ft4 = false;
|
||||
let mut want_ft2 = false;
|
||||
let mut want_wspr = false;
|
||||
let mut want_lrpt = false;
|
||||
|
||||
for decoder in bookmark
|
||||
.decoders
|
||||
.iter()
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
{
|
||||
match decoder.as_str() {
|
||||
"aprs" => want_aprs = true,
|
||||
"hf-aprs" => want_hf_aprs = true,
|
||||
"ft8" => want_ft8 = true,
|
||||
"ft4" => want_ft4 = true,
|
||||
"ft2" => want_ft2 = true,
|
||||
"wspr" => want_wspr = true,
|
||||
"lrpt" => want_lrpt = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
want_aprs,
|
||||
want_hf_aprs,
|
||||
want_ft8,
|
||||
want_ft4,
|
||||
want_ft2,
|
||||
want_wspr,
|
||||
want_lrpt,
|
||||
)
|
||||
}
|
||||
|
||||
async fn apply_selected_channel(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
remote: &str,
|
||||
channel: &crate::server::vchan::SelectedChannel,
|
||||
bookmark_store_map: &crate::server::bookmarks::BookmarkStoreMap,
|
||||
) -> Result<(), Error> {
|
||||
send_command_to_rig(
|
||||
rig_tx,
|
||||
remote,
|
||||
RigCommand::SetMode(parse_mode(&channel.mode)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if channel.bandwidth_hz > 0 {
|
||||
send_command_to_rig(
|
||||
rig_tx,
|
||||
remote,
|
||||
RigCommand::SetBandwidth(channel.bandwidth_hz),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
send_command_to_rig(
|
||||
rig_tx,
|
||||
remote,
|
||||
RigCommand::SetFreq(Freq {
|
||||
hz: channel.freq_hz,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(bookmark) = bookmark_store_map.get_for_rig(remote, bookmark_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_lrpt) =
|
||||
bookmark_decoder_state(&bookmark);
|
||||
let desired = [
|
||||
RigCommand::SetAprsDecodeEnabled(want_aprs),
|
||||
RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs),
|
||||
RigCommand::SetFt8DecodeEnabled(want_ft8),
|
||||
RigCommand::SetFt4DecodeEnabled(want_ft4),
|
||||
RigCommand::SetFt2DecodeEnabled(want_ft2),
|
||||
RigCommand::SetWsprDecodeEnabled(want_wspr),
|
||||
RigCommand::SetLrptDecodeEnabled(want_lrpt),
|
||||
];
|
||||
for cmd in desired {
|
||||
send_command_to_rig(rig_tx, remote, cmd).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
@@ -363,6 +363,13 @@ impl BackgroundDecodeManager {
|
||||
let sample_rate = frame.map(|frame| frame.sample_rate);
|
||||
let half_span_hz = frame.map(|frame| i64::from(frame.sample_rate) / 2);
|
||||
|
||||
let spectrum_span = match (center_hz, half_span_hz) {
|
||||
(Some(c), Some(h)) => Some((c as i64, h)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let scheduled_set: HashSet<String> = scheduled_bookmark_ids.into_iter().collect();
|
||||
|
||||
let mut statuses = Vec::new();
|
||||
let mut desired_channels = HashMap::new();
|
||||
|
||||
@@ -387,59 +394,35 @@ impl BackgroundDecodeManager {
|
||||
channel_kind: None,
|
||||
};
|
||||
|
||||
if decoder_kinds.is_empty() {
|
||||
status.state = "no_supported_decoders".to_string();
|
||||
statuses.push(status);
|
||||
continue;
|
||||
let vchan_covers = self.virtual_channels_cover_bookmark(&rig_id, bookmark);
|
||||
|
||||
let action = evaluate_bookmark(
|
||||
decoder_kinds.is_empty(),
|
||||
config.enabled,
|
||||
users_connected,
|
||||
scheduler_has_control,
|
||||
&scheduled_set,
|
||||
&bookmark.id,
|
||||
vchan_covers,
|
||||
spectrum_span,
|
||||
bookmark.freq_hz,
|
||||
);
|
||||
|
||||
match action {
|
||||
ChannelAction::Active => {
|
||||
status.state = "active".to_string();
|
||||
status.channel_kind = Some(CHANNEL_KIND_NAME.to_string());
|
||||
let desired = self.desired_channel(&rig_id, bookmark, decoder_kinds);
|
||||
desired_channels.insert(bookmark.id.clone(), desired);
|
||||
}
|
||||
ChannelAction::Skip { reason } => {
|
||||
status.state = reason.to_string();
|
||||
if reason == "handled_by_virtual_channel" {
|
||||
status.channel_kind = Some(VISIBLE_CHANNEL_KIND_NAME.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !config.enabled {
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !users_connected {
|
||||
status.state = "waiting_for_user".to_string();
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
|
||||
if scheduler_has_control {
|
||||
status.state = "scheduler_has_control".to_string();
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
|
||||
if scheduled_bookmark_ids.iter().any(|id| id == &bookmark.id) {
|
||||
status.state = "handled_by_scheduler".to_string();
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.virtual_channels_cover_bookmark(&rig_id, bookmark) {
|
||||
status.state = "handled_by_virtual_channel".to_string();
|
||||
status.channel_kind = Some(VISIBLE_CHANNEL_KIND_NAME.to_string());
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (Some(center_hz), Some(half_span_hz)) = (center_hz, half_span_hz) else {
|
||||
status.state = "waiting_for_spectrum".to_string();
|
||||
statuses.push(status);
|
||||
continue;
|
||||
};
|
||||
|
||||
let offset_hz = bookmark.freq_hz as i64 - center_hz as i64;
|
||||
if offset_hz.abs() > half_span_hz {
|
||||
status.state = "out_of_span".to_string();
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
|
||||
status.state = "active".to_string();
|
||||
status.channel_kind = Some(CHANNEL_KIND_NAME.to_string());
|
||||
let desired = self.desired_channel(&rig_id, bookmark, decoder_kinds);
|
||||
desired_channels.insert(bookmark.id.clone(), desired);
|
||||
statuses.push(status);
|
||||
}
|
||||
|
||||
@@ -554,6 +537,70 @@ impl BackgroundDecodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ChannelAction {
|
||||
Active,
|
||||
Skip { reason: &'static str },
|
||||
}
|
||||
|
||||
/// Pure decision function that determines whether a bookmark should produce an
|
||||
/// active background-decode channel or be skipped (with a reason).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn evaluate_bookmark(
|
||||
decoder_kinds_empty: bool,
|
||||
enabled: bool,
|
||||
users_connected: bool,
|
||||
scheduler_has_control: bool,
|
||||
scheduled_bookmark_ids: &HashSet<String>,
|
||||
bookmark_id: &str,
|
||||
vchan_covers_bookmark: bool,
|
||||
spectrum_span: Option<(i64, i64)>,
|
||||
freq_hz: u64,
|
||||
) -> ChannelAction {
|
||||
if decoder_kinds_empty {
|
||||
return ChannelAction::Skip {
|
||||
reason: "no_supported_decoders",
|
||||
};
|
||||
}
|
||||
if !enabled {
|
||||
return ChannelAction::Skip {
|
||||
reason: "disabled",
|
||||
};
|
||||
}
|
||||
if !users_connected {
|
||||
return ChannelAction::Skip {
|
||||
reason: "waiting_for_user",
|
||||
};
|
||||
}
|
||||
if scheduler_has_control {
|
||||
return ChannelAction::Skip {
|
||||
reason: "scheduler_has_control",
|
||||
};
|
||||
}
|
||||
if scheduled_bookmark_ids.contains(bookmark_id) {
|
||||
return ChannelAction::Skip {
|
||||
reason: "handled_by_scheduler",
|
||||
};
|
||||
}
|
||||
if vchan_covers_bookmark {
|
||||
return ChannelAction::Skip {
|
||||
reason: "handled_by_virtual_channel",
|
||||
};
|
||||
}
|
||||
let Some((center_hz, half_span_hz)) = spectrum_span else {
|
||||
return ChannelAction::Skip {
|
||||
reason: "waiting_for_spectrum",
|
||||
};
|
||||
};
|
||||
let offset_hz = freq_hz as i64 - center_hz;
|
||||
if offset_hz.abs() > half_span_hz {
|
||||
return ChannelAction::Skip {
|
||||
reason: "out_of_span",
|
||||
};
|
||||
}
|
||||
ChannelAction::Active
|
||||
}
|
||||
|
||||
fn dedup_ids(ids: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for id in ids {
|
||||
@@ -643,3 +690,163 @@ pub async fn get_background_decode_status(
|
||||
) -> impl Responder {
|
||||
HttpResponse::Ok().json(manager.status(&path.into_inner()).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_scheduled() -> HashSet<String> {
|
||||
HashSet::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_when_all_conditions_met() {
|
||||
let action = evaluate_bookmark(
|
||||
false, // decoder_kinds_empty
|
||||
true, // enabled
|
||||
true, // users_connected
|
||||
false, // scheduler_has_control
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false, // vchan_covers_bookmark
|
||||
Some((14_074_000, 96_000)), // spectrum_span (center, half)
|
||||
14_074_000, // freq_hz
|
||||
);
|
||||
assert_eq!(action, ChannelAction::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_no_supported_decoders() {
|
||||
let action = evaluate_bookmark(
|
||||
true, true, true, false, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "no_supported_decoders"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_disabled() {
|
||||
let action = evaluate_bookmark(
|
||||
false, false, true, false, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(action, ChannelAction::Skip { reason: "disabled" });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_waiting_for_user() {
|
||||
let action = evaluate_bookmark(
|
||||
false, true, false, false, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "waiting_for_user"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_scheduler_has_control() {
|
||||
let action = evaluate_bookmark(
|
||||
false, true, true, true, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "scheduler_has_control"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_handled_by_scheduler() {
|
||||
let mut scheduled = HashSet::new();
|
||||
scheduled.insert("bm1".to_string());
|
||||
let action = evaluate_bookmark(
|
||||
false, true, true, false, &scheduled, "bm1", false,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "handled_by_scheduler"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_handled_by_virtual_channel() {
|
||||
let action = evaluate_bookmark(
|
||||
false, true, true, false, &empty_scheduled(), "bm1", true,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "handled_by_virtual_channel"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_waiting_for_spectrum() {
|
||||
let action = evaluate_bookmark(
|
||||
false, true, true, false, &empty_scheduled(), "bm1", false,
|
||||
None, 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "waiting_for_spectrum"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_out_of_span() {
|
||||
let action = evaluate_bookmark(
|
||||
false, true, true, false, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)), // center 14.074 MHz, half span 96 kHz
|
||||
7_074_000, // way outside the span
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "out_of_span"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_at_edge_of_span() {
|
||||
let action = evaluate_bookmark(
|
||||
false, true, true, false, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000 + 96_000, // exactly at the edge
|
||||
);
|
||||
assert_eq!(action, ChannelAction::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_no_decoders_over_disabled() {
|
||||
// Even if disabled, "no_supported_decoders" should take precedence
|
||||
let action = evaluate_bookmark(
|
||||
true, false, true, false, &empty_scheduled(), "bm1", false,
|
||||
Some((14_074_000, 96_000)), 14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "no_supported_decoders"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
#[path = "api.rs"]
|
||||
#[path = "api/mod.rs"]
|
||||
pub mod api;
|
||||
#[path = "audio.rs"]
|
||||
pub mod audio;
|
||||
|
||||
Reference in New Issue
Block a user