feat(trx-client): add runtime multi-rig discovery and switching
This commit is contained in:
@@ -277,6 +277,8 @@ const loadingTitle = document.getElementById("loading-title");
|
||||
const loadingSub = document.getElementById("loading-sub");
|
||||
const headerSigCanvas = document.getElementById("header-sig-canvas");
|
||||
const themeToggleBtn = document.getElementById("theme-toggle");
|
||||
const rigSwitchSelect = document.getElementById("rig-switch-select");
|
||||
const rigSwitchBtn = document.getElementById("rig-switch-btn");
|
||||
|
||||
let lastControl;
|
||||
let lastTxEn = null;
|
||||
@@ -303,6 +305,7 @@ function vfoColor(idx) {
|
||||
let jogAngle = 0;
|
||||
let lastClientCount = null;
|
||||
let lastLocked = false;
|
||||
let lastRigIds = [];
|
||||
const originalTitle = document.title;
|
||||
const savedTheme = loadSetting("theme", null);
|
||||
|
||||
@@ -941,6 +944,33 @@ function render(update) {
|
||||
if (typeof update.clients === "number") {
|
||||
document.getElementById("about-clients").textContent = update.clients;
|
||||
}
|
||||
if (typeof update.active_rig_id === "string" && update.active_rig_id.length > 0) {
|
||||
document.getElementById("about-active-rig").textContent = update.active_rig_id;
|
||||
}
|
||||
if (Array.isArray(update.rig_ids)) {
|
||||
lastRigIds = update.rig_ids.filter((id) => typeof id === "string" && id.length > 0);
|
||||
document.getElementById("about-rig-list").textContent = lastRigIds.length ? lastRigIds.join(", ") : "--";
|
||||
if (rigSwitchSelect) {
|
||||
const selectedBefore = rigSwitchSelect.value;
|
||||
rigSwitchSelect.innerHTML = "";
|
||||
lastRigIds.forEach((id) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
rigSwitchSelect.appendChild(opt);
|
||||
});
|
||||
const preferred = (typeof update.active_rig_id === "string" && lastRigIds.includes(update.active_rig_id))
|
||||
? update.active_rig_id
|
||||
: selectedBefore;
|
||||
if (preferred && lastRigIds.includes(preferred)) {
|
||||
rigSwitchSelect.value = preferred;
|
||||
}
|
||||
rigSwitchSelect.disabled = lastRigIds.length === 0;
|
||||
if (rigSwitchBtn) {
|
||||
rigSwitchBtn.disabled = lastRigIds.length === 0 || authRole === "rx";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof update.rigctl_clients === "number") {
|
||||
document.getElementById("about-rigctl-clients").textContent = update.rigctl_clients;
|
||||
}
|
||||
@@ -1080,6 +1110,36 @@ async function postPath(path) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
async function switchRig() {
|
||||
if (!rigSwitchSelect || !rigSwitchSelect.value) {
|
||||
showHint("No rig selected", 1500);
|
||||
return;
|
||||
}
|
||||
if (authRole === "rx") {
|
||||
showHint("Control role required", 1500);
|
||||
return;
|
||||
}
|
||||
if (!lastRigIds.includes(rigSwitchSelect.value)) {
|
||||
showHint("Unknown rig", 1500);
|
||||
return;
|
||||
}
|
||||
if (rigSwitchBtn) rigSwitchBtn.disabled = true;
|
||||
showHint("Switching rig…");
|
||||
try {
|
||||
await postPath(`/select_rig?rig_id=${encodeURIComponent(rigSwitchSelect.value)}`);
|
||||
showHint("Rig switch requested", 1500);
|
||||
} catch (err) {
|
||||
showHint("Rig switch failed", 2000);
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (rigSwitchBtn) rigSwitchBtn.disabled = authRole === "rx";
|
||||
}
|
||||
}
|
||||
|
||||
if (rigSwitchBtn) {
|
||||
rigSwitchBtn.addEventListener("click", switchRig);
|
||||
}
|
||||
|
||||
powerBtn.addEventListener("click", async () => {
|
||||
powerBtn.disabled = true;
|
||||
showHint("Sending...");
|
||||
|
||||
@@ -275,6 +275,9 @@
|
||||
<tr><td>Server address</td><td id="about-server-addr">--</td></tr>
|
||||
<tr><td>Server callsign</td><td id="about-server-call">--</td></tr>
|
||||
<tr><td>Rig</td><td id="about-rig-info">--</td></tr>
|
||||
<tr><td>Active rig</td><td id="about-active-rig">--</td></tr>
|
||||
<tr><td>Available rigs</td><td id="about-rig-list">--</td></tr>
|
||||
<tr><td>Switch rig</td><td><select id="rig-switch-select"></select> <button id="rig-switch-btn" type="button">Switch</button></td></tr>
|
||||
<tr><td>Rig connection</td><td id="about-rig-access">--</td></tr>
|
||||
<tr><td>Supported modes</td><td id="about-modes">--</td></tr>
|
||||
<tr><td>VFOs</td><td id="about-vfos">--</td></tr>
|
||||
|
||||
@@ -16,7 +16,7 @@ use tokio_stream::wrappers::{IntervalStream, WatchStream};
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
|
||||
use trx_core::{RigCommand, RigRequest, RigSnapshot, RigState};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||
use trx_protocol::{parse_mode, ClientResponse};
|
||||
|
||||
use crate::server::status;
|
||||
@@ -42,6 +42,8 @@ fn inject_frontend_meta(
|
||||
http_clients: usize,
|
||||
rigctl_clients: usize,
|
||||
rigctl_addr: Option<String>,
|
||||
active_rig_id: Option<String>,
|
||||
rig_ids: Vec<String>,
|
||||
) -> String {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
@@ -59,6 +61,10 @@ fn inject_frontend_meta(
|
||||
if let Some(addr) = rigctl_addr {
|
||||
map.insert("rigctl_addr".to_string(), serde_json::json!(addr));
|
||||
}
|
||||
if let Some(rig_id) = active_rig_id {
|
||||
map.insert("active_rig_id".to_string(), serde_json::json!(rig_id));
|
||||
}
|
||||
map.insert("rig_ids".to_string(), serde_json::json!(rig_ids));
|
||||
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| json.to_string())
|
||||
}
|
||||
@@ -72,6 +78,23 @@ fn rigctl_addr_from_context(context: &FrontendRuntimeContext) -> Option<String>
|
||||
.map(|addr| addr.to_string())
|
||||
}
|
||||
|
||||
fn active_rig_id_from_context(context: &FrontendRuntimeContext) -> Option<String> {
|
||||
context
|
||||
.remote_active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
}
|
||||
|
||||
fn rig_ids_from_context(context: &FrontendRuntimeContext) -> Vec<String> {
|
||||
context
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().map(|r| r.rig_id.clone()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[get("/events")]
|
||||
pub async fn events(
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
@@ -91,6 +114,8 @@ pub async fn events(
|
||||
count,
|
||||
context.rigctl_clients.load(Ordering::Relaxed),
|
||||
rigctl_addr_from_context(context.get_ref().as_ref()),
|
||||
active_rig_id_from_context(context.get_ref().as_ref()),
|
||||
rig_ids_from_context(context.get_ref().as_ref()),
|
||||
);
|
||||
let initial_stream =
|
||||
once(async move { Ok::<Bytes, Error>(Bytes::from(format!("data: {initial_json}\n\n"))) });
|
||||
@@ -108,6 +133,8 @@ pub async fn events(
|
||||
counter.load(Ordering::Relaxed),
|
||||
context.rigctl_clients.load(Ordering::Relaxed),
|
||||
rigctl_addr_from_context(context.as_ref()),
|
||||
active_rig_id_from_context(context.as_ref()),
|
||||
rig_ids_from_context(context.as_ref()),
|
||||
);
|
||||
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n")))
|
||||
})
|
||||
@@ -471,9 +498,90 @@ pub async fn clear_cw_decode(
|
||||
send_command(&rig_tx, RigCommand::ResetCwDecoder).await
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListItem {
|
||||
rig_id: String,
|
||||
manufacturer: String,
|
||||
model: String,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListResponse {
|
||||
active_rig_id: Option<String>,
|
||||
rigs: Vec<RigListItem>,
|
||||
}
|
||||
|
||||
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
|
||||
let active_rig_id = active_rig_id_from_context(context);
|
||||
let rigs = context
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().map(map_rig_entry).collect())
|
||||
.unwrap_or_default();
|
||||
RigListResponse {
|
||||
active_rig_id,
|
||||
rigs,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
|
||||
RigListItem {
|
||||
rig_id: entry.rig_id.clone(),
|
||||
manufacturer: entry.state.info.manufacturer.clone(),
|
||||
model: entry.state.info.model.clone(),
|
||||
initialized: entry.state.initialized,
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/rigs")]
|
||||
pub async fn list_rigs(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SelectRigQuery {
|
||||
pub rig_id: String,
|
||||
}
|
||||
|
||||
#[post("/select_rig")]
|
||||
pub async fn select_rig(
|
||||
query: web::Query<SelectRigQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let rig_id = query.rig_id.trim();
|
||||
if rig_id.is_empty() {
|
||||
return Err(actix_web::error::ErrorBadRequest(
|
||||
"rig_id must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let known = context
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().any(|entry| entry.rig_id == rig_id))
|
||||
.unwrap_or(false);
|
||||
if !known {
|
||||
return Err(actix_web::error::ErrorBadRequest(format!(
|
||||
"unknown rig_id: {rig_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(mut active) = context.remote_active_rig_id.lock() {
|
||||
*active = Some(rig_id.to_string());
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(index)
|
||||
.service(status_api)
|
||||
.service(list_rigs)
|
||||
.service(events)
|
||||
.service(decode_events)
|
||||
.service(toggle_power)
|
||||
@@ -497,6 +605,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(clear_cw_decode)
|
||||
.service(clear_ft8_decode)
|
||||
.service(clear_wspr_decode)
|
||||
.service(select_rig)
|
||||
.service(crate::server::audio::audio_ws)
|
||||
.service(favicon)
|
||||
.service(logo)
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
//! - `Control`: full access including TX/PTT control
|
||||
|
||||
use actix_web::{
|
||||
cookie::Cookie,
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
get, post, web, Error, HttpRequest, HttpResponse, Responder, cookie::Cookie,
|
||||
get, post, web, Error, HttpRequest, HttpResponse, Responder,
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -18,7 +19,6 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
|
||||
/// Unique session identifier (hex-encoded 128-bit random)
|
||||
pub type SessionId = String;
|
||||
|
||||
@@ -309,12 +309,10 @@ pub async fn login(
|
||||
let ttl_secs = auth_state.config.session_ttl.as_secs() as i64;
|
||||
cookie.set_max_age(actix_web::cookie::time::Duration::seconds(ttl_secs));
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.cookie(cookie)
|
||||
.json(LoginResponse {
|
||||
authenticated: true,
|
||||
role: role.as_str().to_string(),
|
||||
}))
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
|
||||
authenticated: true,
|
||||
role: role.as_str().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/logout
|
||||
@@ -338,11 +336,9 @@ pub async fn logout(
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_max_age(actix_web::cookie::time::Duration::seconds(0));
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.cookie(cookie)
|
||||
.json(serde_json::json!({
|
||||
"logged_out": true
|
||||
})))
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(serde_json::json!({
|
||||
"logged_out": true
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /auth/session
|
||||
@@ -424,10 +420,12 @@ impl RouteAccess {
|
||||
|
||||
// Read-only routes
|
||||
if path == "/status"
|
||||
|| path == "/rigs"
|
||||
|| path == "/events"
|
||||
|| path == "/decode"
|
||||
|| path == "/audio"
|
||||
|| path.starts_with("/status?")
|
||||
|| path.starts_with("/rigs?")
|
||||
|| path.starts_with("/events?")
|
||||
|| path.starts_with("/decode?")
|
||||
|| path.starts_with("/audio?")
|
||||
@@ -498,9 +496,7 @@ where
|
||||
}
|
||||
|
||||
// For protected routes, check auth
|
||||
let auth_state = req
|
||||
.app_data::<web::Data<AuthState>>()
|
||||
.cloned();
|
||||
let auth_state = req.app_data::<web::Data<AuthState>>().cloned();
|
||||
|
||||
if let Some(auth_state) = auth_state {
|
||||
if !auth_state.config.enabled {
|
||||
@@ -586,6 +582,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_route_access_read_paths() {
|
||||
assert_eq!(RouteAccess::from_path("/status"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/rigs"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read);
|
||||
@@ -593,14 +590,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_route_access_control_paths() {
|
||||
assert_eq!(
|
||||
RouteAccess::from_path("/set_freq"),
|
||||
RouteAccess::Control
|
||||
);
|
||||
assert_eq!(
|
||||
RouteAccess::from_path("/set_mode"),
|
||||
RouteAccess::Control
|
||||
);
|
||||
assert_eq!(RouteAccess::from_path("/set_freq"), RouteAccess::Control);
|
||||
assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user