refactor: nest trx-frontend under trx-client, trx-backend under trx-server
Move the frontend and backend crate trees to live physically under their respective binary crate directories, grouping related code together without merging crate boundaries. Also flatten sub-crate nesting by moving them out of src/ subdirectories into direct children. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
|
||||
/// Trait implemented by concrete frontends to expose a runner entrypoint.
|
||||
pub trait FrontendSpawner {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> JoinHandle<()>;
|
||||
}
|
||||
|
||||
type FrontendSpawnFn = fn(
|
||||
watch::Receiver<RigState>,
|
||||
mpsc::Sender<RigRequest>,
|
||||
Option<String>,
|
||||
SocketAddr,
|
||||
) -> JoinHandle<()>;
|
||||
|
||||
struct FrontendRegistry {
|
||||
spawners: HashMap<String, FrontendSpawnFn>,
|
||||
}
|
||||
|
||||
impl FrontendRegistry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
spawners: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<FrontendRegistry> {
|
||||
static REGISTRY: OnceLock<Mutex<FrontendRegistry>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(FrontendRegistry::new()))
|
||||
}
|
||||
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Register a frontend spawner under a stable name (e.g. "http").
|
||||
pub fn register_frontend(name: &str, spawner: FrontendSpawnFn) {
|
||||
let key = normalize_name(name);
|
||||
let mut reg = registry().lock().expect("frontend registry mutex poisoned");
|
||||
reg.spawners.insert(key, spawner);
|
||||
}
|
||||
|
||||
/// Check whether a frontend name is registered.
|
||||
pub fn is_frontend_registered(name: &str) -> bool {
|
||||
let key = normalize_name(name);
|
||||
let reg = registry().lock().expect("frontend registry mutex poisoned");
|
||||
reg.spawners.contains_key(&key)
|
||||
}
|
||||
|
||||
/// List registered frontend names.
|
||||
pub fn registered_frontends() -> Vec<String> {
|
||||
let reg = registry().lock().expect("frontend registry mutex poisoned");
|
||||
let mut names: Vec<String> = reg.spawners.keys().cloned().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Spawn a registered frontend by name.
|
||||
pub fn spawn_frontend(
|
||||
name: &str,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> DynResult<JoinHandle<()>> {
|
||||
let key = normalize_name(name);
|
||||
let reg = registry().lock().expect("frontend registry mutex poisoned");
|
||||
let spawner = reg
|
||||
.spawners
|
||||
.get(&key)
|
||||
.ok_or_else(|| format!("Unknown frontend: {}", name))?;
|
||||
Ok(spawner(state_rx, rig_tx, callsign, listen_addr))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-http-json"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub fn register_frontend() {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
trx_frontend::register_frontend("http-json", server::HttpJsonFrontend::spawn_frontend);
|
||||
}
|
||||
|
||||
pub fn set_auth_tokens(tokens: Vec<String>) {
|
||||
server::set_auth_tokens(tokens);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use trx_core::client::ClientEnvelope;
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::command::RigCommand;
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::{RigMode, RigState};
|
||||
use trx_core::{ClientCommand, ClientResponse};
|
||||
use trx_frontend::FrontendSpawner;
|
||||
|
||||
/// JSON-over-TCP frontend for control and status.
|
||||
pub struct HttpJsonFrontend;
|
||||
|
||||
struct AuthConfig {
|
||||
tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
fn auth_registry() -> &'static Mutex<AuthConfig> {
|
||||
static REGISTRY: OnceLock<Mutex<AuthConfig>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
Mutex::new(AuthConfig {
|
||||
tokens: HashSet::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_auth_tokens(tokens: Vec<String>) {
|
||||
let mut reg = auth_registry()
|
||||
.lock()
|
||||
.expect("http-json auth mutex poisoned");
|
||||
reg.tokens = tokens.into_iter().filter(|t| !t.is_empty()).collect();
|
||||
}
|
||||
|
||||
impl FrontendSpawner for HttpJsonFrontend {
|
||||
fn spawn_frontend(
|
||||
_state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(listen_addr, rig_tx).await {
|
||||
error!("json tcp server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(listen_addr: SocketAddr, rig_tx: mpsc::Sender<RigRequest>) -> std::io::Result<()> {
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
info!("json tcp frontend listening on {}", listen_addr);
|
||||
|
||||
loop {
|
||||
let (socket, addr) = listener.accept().await?;
|
||||
info!("json tcp client connected: {}", addr);
|
||||
|
||||
let tx_clone = rig_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(socket, addr, tx_clone).await {
|
||||
error!("json tcp client {} error: {:?}", addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
socket: TcpStream,
|
||||
addr: SocketAddr,
|
||||
tx: mpsc::Sender<RigRequest>,
|
||||
) -> std::io::Result<()> {
|
||||
let (reader, mut writer) = socket.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes_read = reader.read_line(&mut line).await?;
|
||||
if bytes_read == 0 {
|
||||
info!("json tcp client {} disconnected", addr);
|
||||
break;
|
||||
}
|
||||
|
||||
// Simple protocol: one line = one JSON command.
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let envelope = match parse_envelope(trimmed) {
|
||||
Ok(envelope) => envelope,
|
||||
Err(e) => {
|
||||
error!("Invalid JSON from {}: {} / {:?}", addr, trimmed, e);
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
state: None,
|
||||
error: Some(format!("Invalid JSON: {}", e)),
|
||||
};
|
||||
let resp_line = serde_json::to_string(&resp)? + "\n";
|
||||
writer.write_all(resp_line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = authorize(&envelope.token) {
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
state: None,
|
||||
error: Some(err),
|
||||
};
|
||||
let resp_line = serde_json::to_string(&resp)? + "\n";
|
||||
writer.write_all(resp_line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map ClientCommand -> RigCommand.
|
||||
let rig_cmd = match envelope.cmd {
|
||||
ClientCommand::GetState => RigCommand::GetSnapshot,
|
||||
ClientCommand::SetFreq { freq_hz } => RigCommand::SetFreq(Freq { hz: freq_hz }),
|
||||
ClientCommand::SetMode { mode } => RigCommand::SetMode(parse_mode(&mode)),
|
||||
ClientCommand::SetPtt { ptt } => RigCommand::SetPtt(ptt),
|
||||
ClientCommand::PowerOn => RigCommand::PowerOn,
|
||||
ClientCommand::PowerOff => RigCommand::PowerOff,
|
||||
ClientCommand::ToggleVfo => RigCommand::ToggleVfo,
|
||||
ClientCommand::Lock => RigCommand::Lock,
|
||||
ClientCommand::Unlock => RigCommand::Unlock,
|
||||
ClientCommand::GetTxLimit => RigCommand::GetTxLimit,
|
||||
ClientCommand::SetTxLimit { limit } => RigCommand::SetTxLimit(limit),
|
||||
};
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
let req = RigRequest {
|
||||
cmd: rig_cmd,
|
||||
respond_to: resp_tx,
|
||||
};
|
||||
|
||||
if let Err(e) = tx.send(req).await {
|
||||
error!("Failed to send request to rig_task: {:?}", e);
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
state: None,
|
||||
error: Some("Internal error: rig task not available".into()),
|
||||
};
|
||||
let resp_line = serde_json::to_string(&resp)? + "\n";
|
||||
writer.write_all(resp_line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
match resp_rx.await {
|
||||
Ok(Ok(snapshot)) => {
|
||||
let resp = ClientResponse {
|
||||
success: true,
|
||||
state: Some(snapshot),
|
||||
error: None,
|
||||
};
|
||||
let resp_line = serde_json::to_string(&resp)? + "\n";
|
||||
writer.write_all(resp_line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
state: None,
|
||||
error: Some(err.message),
|
||||
};
|
||||
let resp_line = serde_json::to_string(&resp)? + "\n";
|
||||
writer.write_all(resp_line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Rig response oneshot recv error: {:?}", e);
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
state: None,
|
||||
error: Some("Internal error waiting for rig response".into()),
|
||||
};
|
||||
let resp_line = serde_json::to_string(&resp)? + "\n";
|
||||
writer.write_all(resp_line.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_mode(s: &str) -> RigMode {
|
||||
match s.to_uppercase().as_str() {
|
||||
"LSB" => RigMode::LSB,
|
||||
"USB" => RigMode::USB,
|
||||
"CW" => RigMode::CW,
|
||||
"CWR" => RigMode::CWR,
|
||||
"AM" => RigMode::AM,
|
||||
"FM" => RigMode::FM,
|
||||
"DIG" | "DIGI" => RigMode::DIG,
|
||||
"PKT" | "PACKET" => RigMode::PKT,
|
||||
other => RigMode::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_envelope(input: &str) -> Result<ClientEnvelope, serde_json::Error> {
|
||||
match serde_json::from_str::<ClientEnvelope>(input) {
|
||||
Ok(envelope) => Ok(envelope),
|
||||
Err(_) => {
|
||||
let cmd = serde_json::from_str::<ClientCommand>(input)?;
|
||||
Ok(ClientEnvelope { token: None, cmd })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize(token: &Option<String>) -> Result<(), String> {
|
||||
let reg = auth_registry()
|
||||
.lock()
|
||||
.expect("http-json auth mutex poisoned");
|
||||
if reg.tokens.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(token) = token.as_ref() else {
|
||||
return Err("missing authorization token".into());
|
||||
};
|
||||
|
||||
let candidate = strip_bearer(token);
|
||||
if reg.tokens.contains(candidate) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err("invalid authorization token".into())
|
||||
}
|
||||
|
||||
fn strip_bearer(value: &str) -> &str {
|
||||
let trimmed = value.trim();
|
||||
let prefix = "bearer ";
|
||||
if trimmed.len() >= prefix.len() && trimmed[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
||||
&trimmed[prefix.len()..]
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-http"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
actix-web = "=4.4.1"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,303 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
||||
use actix_web::{http::header, Error};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{once, select, StreamExt};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::time::{self, Duration};
|
||||
use tokio_stream::wrappers::{IntervalStream, WatchStream};
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
|
||||
use trx_core::{ClientResponse, RigCommand, RigMode, RigRequest, RigSnapshot, RigState};
|
||||
|
||||
use crate::server::status;
|
||||
|
||||
const FAVICON_BYTES: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/trx-favicon.png"
|
||||
));
|
||||
const LOGO_BYTES: &[u8] =
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-logo.png"));
|
||||
|
||||
#[get("/status")]
|
||||
pub async fn status_api(
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
let state = wait_for_view(state.get_ref().clone()).await?;
|
||||
Ok(HttpResponse::Ok().json(state))
|
||||
}
|
||||
|
||||
#[get("/events")]
|
||||
pub async fn events(state: web::Data<watch::Receiver<RigState>>) -> Result<HttpResponse, Error> {
|
||||
let rx = state.get_ref().clone();
|
||||
let initial = wait_for_view(rx.clone()).await?;
|
||||
|
||||
let initial_json =
|
||||
serde_json::to_string(&initial).map_err(actix_web::error::ErrorInternalServerError)?;
|
||||
let initial_stream =
|
||||
once(async move { Ok::<Bytes, Error>(Bytes::from(format!("data: {initial_json}\n\n"))) });
|
||||
|
||||
let updates = WatchStream::new(rx).filter_map(|state| async move {
|
||||
state
|
||||
.snapshot()
|
||||
.and_then(|v| serde_json::to_string(&v).ok())
|
||||
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
|
||||
});
|
||||
|
||||
let pings = IntervalStream::new(time::interval(Duration::from_secs(10)))
|
||||
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
|
||||
|
||||
let stream = initial_stream.chain(select(pings, updates));
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
|
||||
#[post("/toggle_power")]
|
||||
pub async fn toggle_power(
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let desired_on = !matches!(state.get_ref().borrow().control.enabled, Some(true));
|
||||
let cmd = if desired_on {
|
||||
RigCommand::PowerOn
|
||||
} else {
|
||||
RigCommand::PowerOff
|
||||
};
|
||||
send_command(&rig_tx, cmd).await
|
||||
}
|
||||
|
||||
#[post("/toggle_vfo")]
|
||||
pub async fn toggle_vfo(
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::ToggleVfo).await
|
||||
}
|
||||
|
||||
#[post("/lock")]
|
||||
pub async fn lock_panel(
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::Lock).await
|
||||
}
|
||||
|
||||
#[post("/unlock")]
|
||||
pub async fn unlock_panel(
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::Unlock).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FreqQuery {
|
||||
pub hz: u64,
|
||||
}
|
||||
|
||||
#[post("/set_freq")]
|
||||
pub async fn set_freq(
|
||||
query: web::Query<FreqQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: query.hz })).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ModeQuery {
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[post("/set_mode")]
|
||||
pub async fn set_mode(
|
||||
query: web::Query<ModeQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let mode = parse_mode(&query.mode);
|
||||
send_command(&rig_tx, RigCommand::SetMode(mode)).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PttQuery {
|
||||
pub ptt: String,
|
||||
}
|
||||
|
||||
#[post("/set_ptt")]
|
||||
pub async fn set_ptt(
|
||||
query: web::Query<PttQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let ptt = match query.ptt.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "on" => Ok(true),
|
||||
"0" | "false" | "off" => Ok(false),
|
||||
other => Err(actix_web::error::ErrorBadRequest(format!(
|
||||
"invalid ptt parameter: {other}"
|
||||
))),
|
||||
}?;
|
||||
send_command(&rig_tx, RigCommand::SetPtt(ptt)).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TxLimitQuery {
|
||||
pub limit: u8,
|
||||
}
|
||||
|
||||
#[post("/set_tx_limit")]
|
||||
pub async fn set_tx_limit(
|
||||
query: web::Query<TxLimitQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::SetTxLimit(query.limit)).await
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(index)
|
||||
.service(status_api)
|
||||
.service(events)
|
||||
.service(toggle_power)
|
||||
.service(toggle_vfo)
|
||||
.service(lock_panel)
|
||||
.service(unlock_panel)
|
||||
.service(set_freq)
|
||||
.service(set_mode)
|
||||
.service(set_ptt)
|
||||
.service(set_tx_limit)
|
||||
.service(favicon)
|
||||
.service(logo);
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn index(callsign: web::Data<Option<String>>) -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8"))
|
||||
.body(status::index_html(callsign.get_ref().as_deref()))
|
||||
}
|
||||
|
||||
#[get("/favicon.ico")]
|
||||
async fn favicon() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.body(FAVICON_BYTES)
|
||||
}
|
||||
|
||||
#[get("/logo.png")]
|
||||
async fn logo() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.body(LOGO_BYTES)
|
||||
}
|
||||
|
||||
async fn send_command(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
cmd: RigCommand,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
rig_tx
|
||||
.send(RigRequest {
|
||||
cmd,
|
||||
respond_to: resp_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
actix_web::error::ErrorInternalServerError(format!("failed to send to rig: {e:?}"))
|
||||
})?;
|
||||
|
||||
let resp = tokio::time::timeout(Duration::from_secs(8), resp_rx)
|
||||
.await
|
||||
.map_err(|_| actix_web::error::ErrorGatewayTimeout("rig response timeout"))?;
|
||||
|
||||
match resp {
|
||||
Ok(Ok(snapshot)) => Ok(HttpResponse::Ok().json(ClientResponse {
|
||||
success: true,
|
||||
state: Some(snapshot),
|
||||
error: None,
|
||||
})),
|
||||
Ok(Err(err)) => Ok(HttpResponse::BadRequest().json(ClientResponse {
|
||||
success: false,
|
||||
state: None,
|
||||
error: Some(err.message),
|
||||
})),
|
||||
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
|
||||
"rig response channel error: {e:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot, actix_web::Error> {
|
||||
if let Some(view) = rx.borrow().snapshot() {
|
||||
return Ok(view);
|
||||
}
|
||||
|
||||
while rx.changed().await.is_ok() {
|
||||
if let Some(view) = rx.borrow().snapshot() {
|
||||
return Ok(view);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: build a minimal snapshot if rig info is missing.
|
||||
let state = rx.borrow().clone();
|
||||
Ok(RigSnapshot {
|
||||
info: state
|
||||
.rig_info
|
||||
.clone()
|
||||
.unwrap_or_else(|| RigInfoPlaceholder.into()),
|
||||
status: state.status,
|
||||
band: None,
|
||||
enabled: state.control.enabled,
|
||||
initialized: state.initialized,
|
||||
})
|
||||
}
|
||||
|
||||
struct RigInfoPlaceholder;
|
||||
|
||||
impl Default for RigInfoPlaceholder {
|
||||
fn default() -> Self {
|
||||
RigInfoPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RigInfoPlaceholder> for RigInfo {
|
||||
fn from(_: RigInfoPlaceholder) -> Self {
|
||||
RigInfo {
|
||||
manufacturer: "Unknown".to_string(),
|
||||
model: "Rig".to_string(),
|
||||
revision: "".to_string(),
|
||||
capabilities: RigCapabilities {
|
||||
supported_bands: vec![],
|
||||
supported_modes: vec![],
|
||||
num_vfos: 0,
|
||||
lock: false,
|
||||
lockable: false,
|
||||
attenuator: false,
|
||||
preamp: false,
|
||||
rit: false,
|
||||
rpt: false,
|
||||
split: false,
|
||||
},
|
||||
access: RigAccessMethod::Serial {
|
||||
path: "".into(),
|
||||
baud: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mode(s: &str) -> RigMode {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"LSB" => RigMode::LSB,
|
||||
"USB" => RigMode::USB,
|
||||
"CW" => RigMode::CW,
|
||||
"CWR" => RigMode::CWR,
|
||||
"AM" => RigMode::AM,
|
||||
"FM" => RigMode::FM,
|
||||
"WFM" => RigMode::WFM,
|
||||
"DIG" | "DIGI" => RigMode::DIG,
|
||||
"PKT" | "PACKET" => RigMode::PKT,
|
||||
other => RigMode::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub fn register_frontend() {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
trx_frontend::register_frontend("http", server::HttpFrontend::spawn_frontend);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
#[path = "api.rs"]
|
||||
mod api;
|
||||
#[path = "status.rs"]
|
||||
pub mod status;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use actix_web::dev::Server;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use trx_core::RigRequest;
|
||||
use trx_core::RigState;
|
||||
use trx_frontend::FrontendSpawner;
|
||||
|
||||
/// HTTP frontend implementation.
|
||||
pub struct HttpFrontend;
|
||||
|
||||
impl FrontendSpawner for HttpFrontend {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(listen_addr, state_rx, rig_tx, callsign).await {
|
||||
error!("HTTP status server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
addr: SocketAddr,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
) -> Result<(), actix_web::Error> {
|
||||
let server = build_server(addr, state_rx, rig_tx, callsign)?;
|
||||
let handle = server.handle();
|
||||
tokio::spawn(async move {
|
||||
let _ = signal::ctrl_c().await;
|
||||
handle.stop(false).await;
|
||||
});
|
||||
info!("http frontend listening on {}", addr);
|
||||
info!("http frontend ready (status/control)");
|
||||
server.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_server(
|
||||
addr: SocketAddr,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
) -> Result<Server, actix_web::Error> {
|
||||
let state_data = web::Data::new(state_rx);
|
||||
let rig_tx = web::Data::new(rig_tx);
|
||||
let callsign = web::Data::new(callsign);
|
||||
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(state_data.clone())
|
||||
.app_data(rig_tx.clone())
|
||||
.app_data(callsign.clone())
|
||||
.configure(api::configure)
|
||||
})
|
||||
.shutdown_timeout(1)
|
||||
.disable_signals()
|
||||
.bind(addr)?
|
||||
.run();
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.configure(api::configure);
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub fn index_html(callsign: Option<&str>) -> String {
|
||||
INDEX_HTML_TEMPLATE
|
||||
.replace("{pkg}", PKG_NAME)
|
||||
.replace("{ver}", PKG_VERSION)
|
||||
.replace("{callsign_opt}", callsign.unwrap_or(""))
|
||||
}
|
||||
|
||||
const INDEX_HTML_TEMPLATE: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{pkg} v{ver} status</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d1117; color: #e5e7eb; }
|
||||
.card { border: 1px solid #1f2a35; border-radius: 12px; padding: 1.25rem 1.75rem; width: min(680px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,0.35); background: #161b22; }
|
||||
.label { color: #9aa4b5; font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||
.value { font-size: 1.4rem; margin-bottom: 0.5rem; }
|
||||
.status { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.1rem 1rem; }
|
||||
input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem; font-size: 1rem; border: 1px solid #2d3748; border-radius: 6px; background: #0f1720; color: #e5e7eb; }
|
||||
.vfo-box { width: 100%; min-height: 2.6rem; padding: 0.45rem 0.5rem; border: 1px solid #2d3748; border-radius: 6px; background: #0f1720; color: #e5e7eb; white-space: pre-line; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.controls { margin-top: 1rem; display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
|
||||
button { padding: 0.5rem 0.9rem; border-radius: 6px; border: 1px solid #394455; background: #1f2937; color: #e5e7eb; cursor: pointer; height: 2.4rem; }
|
||||
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.hint { color: #9aa4b5; font-size: 0.85rem; }
|
||||
.inline { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.section-title { margin-top: 0.5rem; font-size: 1.05rem; font-weight: 600; color: #c5cedd; }
|
||||
small { color: #9aa4b5; }
|
||||
.header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.25rem; }
|
||||
.title { font-size: 1.4rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; position: relative; z-index: 2; }
|
||||
.logo-bg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; opacity: 0.2; }
|
||||
.logo-bg img { max-width: 50%; max-height: 50%; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); }
|
||||
.subtitle { color: #9aa4b5; font-size: 0.95rem; }
|
||||
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: #1f2937; color: #e5e7eb; font-size: 0.82rem; border: 1px solid #2d3748; margin-left: 6px; }
|
||||
.signal { display: flex; gap: 0.6rem; align-items: center; }
|
||||
.signal-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: #1f2937; border: 1px solid #2d3748; overflow: hidden; }
|
||||
.signal-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #00d17f, #f0ad4e, #e55353); transition: width 150ms ease; }
|
||||
.signal-value { font-size: 0.95rem; color: #c5cedd; min-width: 48px; text-align: right; }
|
||||
.meter { display: flex; gap: 0.6rem; align-items: center; }
|
||||
.meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: #1f2937; border: 1px solid #2d3748; overflow: hidden; }
|
||||
.meter-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #00d17f, #f0ad4e, #e55353); transition: width 150ms ease; }
|
||||
.meter-value { font-size: 0.95rem; color: #c5cedd; min-width: 64px; text-align: right; }
|
||||
.footer { margin-top: 0.6rem; display: flex; justify-content: flex-end; }
|
||||
.full-row { grid-column: 1 / -1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" id="card" style="position:relative; overflow:hidden;">
|
||||
<div class="logo-bg"><img id="logo" src="/logo.png?v=1" alt="trx logo" onerror="console.error('logo load failed'); this.style.display='none'" /></div>
|
||||
<div class="header" style="position:relative; z-index:2;">
|
||||
<div>
|
||||
<div class="title"><span id="rig-title">Rig status</span></div>
|
||||
<div class="subtitle">{pkg} v{ver}</div>
|
||||
</div>
|
||||
<div id="callsign" style="color:#9aa4b5; font-weight:600; display:none;">{callsign_opt}</div>
|
||||
</div>
|
||||
<div id="loading" style="text-align:center; padding:2rem 0;">
|
||||
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
|
||||
<div id="loading-sub" style="color:#9aa4b5;"></div>
|
||||
</div>
|
||||
<div id="content" style="display:none;">
|
||||
<div class="status">
|
||||
<div>
|
||||
<div class="label">Frequency<span class="band-tag" id="band-label">--</span></div>
|
||||
<div class="inline">
|
||||
<input class="status-input" id="freq" type="text" value="--" />
|
||||
<button id="freq-apply" type="button">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Mode</div>
|
||||
<div class="inline">
|
||||
<select class="status-input" id="mode">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
<button id="mode-apply" type="button">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Transmit / VFO / Power</div>
|
||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap;">
|
||||
<button id="ptt-btn" type="button" style="flex: 1 1 30%;">Toggle PTT</button>
|
||||
<button id="vfo-btn" type="button" style="flex: 1 1 30%;">VFO</button>
|
||||
<button id="power-btn" type="button" style="flex: 1 1 30%;">Toggle Power</button>
|
||||
<button id="lock-btn" type="button" style="flex: 1 1 30%;">Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 0.9rem;">
|
||||
<div class="label">VFO</div>
|
||||
<div class="vfo-box" id="vfo">--</div>
|
||||
</div>
|
||||
<div class="full-row">
|
||||
<div class="label">Signal</div>
|
||||
<div class="signal" style="gap: 1rem;">
|
||||
<div class="signal-bar"><div class="signal-bar-fill" id="signal-bar"></div></div>
|
||||
<div class="signal-value" id="signal-value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row" id="tx-meters" style="display:none;">
|
||||
<div class="label">TX Meters</div>
|
||||
<div class="meter" style="gap: 1rem; margin-bottom: 0.4rem;">
|
||||
<div class="meter-bar"><div class="meter-fill" id="pwr-bar"></div></div>
|
||||
<div class="meter-value" id="pwr-value">PWR --</div>
|
||||
</div>
|
||||
<div class="meter" style="gap: 1rem;">
|
||||
<div class="meter-bar"><div class="meter-fill" id="swr-bar"></div></div>
|
||||
<div class="meter-value" id="swr-value">SWR --</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tx-limit-row" style="display:none;">
|
||||
<div class="label">TX Limit</div>
|
||||
<div class="inline">
|
||||
<input class="status-input" id="tx-limit" type="number" min="0" max="255" step="1" value="" placeholder="--" />
|
||||
<button id="tx-limit-btn" type="button">Set</button>
|
||||
</div>
|
||||
<small>Units depend on rig (percent/watts).</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="hint" id="power-hint">Connecting…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const freqEl = document.getElementById("freq");
|
||||
const modeEl = document.getElementById("mode");
|
||||
const bandLabel = document.getElementById("band-label");
|
||||
const powerBtn = document.getElementById("power-btn");
|
||||
const powerHint = document.getElementById("power-hint");
|
||||
const vfoEl = document.getElementById("vfo");
|
||||
const vfoBtn = document.getElementById("vfo-btn");
|
||||
const signalBar = document.getElementById("signal-bar");
|
||||
const signalValue = document.getElementById("signal-value");
|
||||
const pttBtn = document.getElementById("ptt-btn");
|
||||
const freqBtn = document.getElementById("freq-apply");
|
||||
const modeBtn = document.getElementById("mode-apply");
|
||||
const txLimitInput = document.getElementById("tx-limit");
|
||||
const txLimitBtn = document.getElementById("tx-limit-btn");
|
||||
const txLimitRow = document.getElementById("tx-limit-row");
|
||||
const lockBtn = document.getElementById("lock-btn");
|
||||
const txMeters = document.getElementById("tx-meters");
|
||||
const pwrBar = document.getElementById("pwr-bar");
|
||||
const pwrValue = document.getElementById("pwr-value");
|
||||
const swrBar = document.getElementById("swr-bar");
|
||||
const swrValue = document.getElementById("swr-value");
|
||||
const loadingEl = document.getElementById("loading");
|
||||
const contentEl = document.getElementById("content");
|
||||
const callsignEl = document.getElementById("callsign");
|
||||
const loadingTitle = document.getElementById("loading-title");
|
||||
const loadingSub = document.getElementById("loading-sub");
|
||||
|
||||
let lastControl;
|
||||
let lastTxEn = null;
|
||||
let lastRendered = null;
|
||||
let rigName = "Rig";
|
||||
let supportedModes = [];
|
||||
let supportedBands = [];
|
||||
let freqDirty = false;
|
||||
let modeDirty = false;
|
||||
let initialized = false;
|
||||
let lastEventAt = Date.now();
|
||||
let es;
|
||||
let esHeartbeat;
|
||||
|
||||
function formatFreq(hz) {
|
||||
if (!Number.isFinite(hz)) return "--";
|
||||
if (hz >= 1_000_000_000) {
|
||||
return `${(hz / 1_000_000_000).toFixed(3)} GHz`;
|
||||
}
|
||||
if (hz >= 10_000_000) {
|
||||
return `${(hz / 1_000_000).toFixed(3)} MHz`;
|
||||
}
|
||||
return `${(hz / 1_000).toFixed(1)} kHz`;
|
||||
}
|
||||
|
||||
function parseFreqInput(val) {
|
||||
if (!val) return null;
|
||||
const trimmed = val.trim().toLowerCase();
|
||||
const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/);
|
||||
if (!match) return null;
|
||||
let num = parseFloat(match[1].replace(",", "."));
|
||||
const unit = match[2] || "";
|
||||
if (Number.isNaN(num)) return null;
|
||||
if (unit.startsWith("gh") || unit === "g") {
|
||||
num *= 1_000_000_000;
|
||||
} else if (unit.startsWith("mh") || unit === "m") {
|
||||
num *= 1_000_000;
|
||||
} else if (unit.startsWith("kh") || unit === "k") {
|
||||
num *= 1_000;
|
||||
} else if (!unit) {
|
||||
// Heuristic when no unit is provided: large numbers are kHz/Hz, small numbers are MHz.
|
||||
if (num >= 1_000_000) {
|
||||
// Assume already Hz.
|
||||
} else if (num >= 1_000) {
|
||||
num *= 1_000; // treat as kHz
|
||||
} else {
|
||||
num *= 1_000_000; // treat as MHz
|
||||
}
|
||||
}
|
||||
return Math.round(num);
|
||||
}
|
||||
|
||||
function normalizeMode(modeVal) {
|
||||
if (typeof modeVal === "string") return modeVal;
|
||||
if (modeVal && typeof modeVal === "object") {
|
||||
const entries = Object.entries(modeVal);
|
||||
if (entries.length > 0) {
|
||||
const [variant, value] = entries[0];
|
||||
if (variant === "Other" && typeof value === "string") return value;
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateSupportedBands(cap) {
|
||||
if (cap && Array.isArray(cap.supported_bands)) {
|
||||
supportedBands = cap.supported_bands
|
||||
.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number" && b.tx_allowed === true)
|
||||
.map((b) => ({ low: b.low_hz, high: b.high_hz }));
|
||||
} else {
|
||||
supportedBands = [];
|
||||
}
|
||||
}
|
||||
|
||||
function freqAllowed(hz) {
|
||||
if (!Number.isFinite(hz)) return false;
|
||||
if (supportedBands.length === 0) return true; // if unknown, don't block
|
||||
return supportedBands.some((b) => hz >= b.low && hz <= b.high);
|
||||
}
|
||||
|
||||
function setDisabled(disabled) {
|
||||
[freqEl, modeEl, freqBtn, modeBtn, pttBtn, vfoBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function render(update) {
|
||||
if (!update) return;
|
||||
if (update.info && update.info.model) {
|
||||
rigName = update.info.model;
|
||||
}
|
||||
document.getElementById("rig-title").textContent = `${rigName} status`;
|
||||
|
||||
initialized = !!update.initialized;
|
||||
if (!initialized) {
|
||||
const manu = (update.info && update.info.manufacturer) || rigName || "Rig";
|
||||
const model = (update.info && update.info.model) || rigName || "Rig";
|
||||
const rev = (update.info && update.info.revision) || "";
|
||||
const parts = [manu, model, rev].filter(Boolean).join(" ");
|
||||
loadingTitle.textContent = `Initializing ${parts}…`;
|
||||
loadingSub.textContent = "";
|
||||
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
|
||||
loadingEl.style.display = "";
|
||||
if (contentEl) contentEl.style.display = "none";
|
||||
powerHint.textContent = "Initializing rig…";
|
||||
setDisabled(true);
|
||||
return;
|
||||
} else {
|
||||
loadingEl.style.display = "none";
|
||||
if (contentEl) contentEl.style.display = "";
|
||||
}
|
||||
// Reveal callsign if provided and non-empty.
|
||||
if (callsignEl && callsignEl.textContent.trim() !== "") {
|
||||
callsignEl.style.display = "";
|
||||
}
|
||||
setDisabled(false);
|
||||
if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) {
|
||||
const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean);
|
||||
if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) {
|
||||
supportedModes = modes;
|
||||
modeEl.innerHTML = "";
|
||||
const empty = document.createElement("option");
|
||||
empty.value = "";
|
||||
empty.textContent = "--";
|
||||
modeEl.appendChild(empty);
|
||||
supportedModes.forEach((m) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
modeEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (update.info && update.info.capabilities) {
|
||||
updateSupportedBands(update.info.capabilities);
|
||||
}
|
||||
if (!freqDirty && update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
||||
freqEl.value = formatFreq(update.status.freq.hz);
|
||||
}
|
||||
if (!modeDirty && update.status && update.status.mode) {
|
||||
const mode = normalizeMode(update.status.mode);
|
||||
modeEl.value = mode ? mode.toUpperCase() : "";
|
||||
}
|
||||
if (update.status && typeof update.status.tx_en === "boolean") {
|
||||
lastTxEn = update.status.tx_en;
|
||||
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
|
||||
pttBtn.style.background = update.status.tx_en ? "#ffefef" : "#f3f3f3";
|
||||
pttBtn.style.borderColor = update.status.tx_en ? "#d22" : "#999";
|
||||
pttBtn.style.color = update.status.tx_en ? "#a00" : "#222";
|
||||
}
|
||||
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
|
||||
const entries = update.status.vfo.entries;
|
||||
const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null;
|
||||
const parts = entries.map((entry, idx) => {
|
||||
const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null;
|
||||
if (hz === null) return null;
|
||||
const mark = activeIdx === idx ? " *" : "";
|
||||
const mode = entry.mode ? normalizeMode(entry.mode) : "";
|
||||
const modeText = mode ? ` [${mode}]` : "";
|
||||
return `${entry.name || `VFO ${idx + 1}`}: ${formatFreq(hz)}${modeText}${mark}`;
|
||||
}).filter(Boolean);
|
||||
vfoEl.textContent = parts.join("\n") || "--";
|
||||
const activeLabel = activeIdx !== null
|
||||
? `VFO ${activeIdx + 1}${entries[activeIdx] && entries[activeIdx].name ? ` (${entries[activeIdx].name})` : ""}`
|
||||
: "VFO";
|
||||
vfoBtn.textContent = activeLabel;
|
||||
} else {
|
||||
vfoEl.textContent = "--";
|
||||
vfoBtn.textContent = "VFO";
|
||||
}
|
||||
if (update.status && update.status.rx && typeof update.status.rx.sig === "number") {
|
||||
const raw = Math.max(0, update.status.rx.sig);
|
||||
let pct;
|
||||
let label;
|
||||
if (raw <= 9) {
|
||||
pct = Math.max(0, Math.min(100, (raw / 9) * 100));
|
||||
label = `S${raw.toFixed(1)}`;
|
||||
} else {
|
||||
const overDb = (raw - 9) * 10;
|
||||
pct = 100;
|
||||
label = `S9 + ${overDb.toFixed(0)}dB`;
|
||||
}
|
||||
signalBar.style.width = `${pct}%`;
|
||||
signalValue.textContent = label;
|
||||
} else {
|
||||
signalBar.style.width = "0%";
|
||||
signalValue.textContent = "--";
|
||||
}
|
||||
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
|
||||
if (typeof update.enabled === "boolean") {
|
||||
powerBtn.disabled = false;
|
||||
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
|
||||
powerHint.textContent = "Ready";
|
||||
} else {
|
||||
powerBtn.disabled = true;
|
||||
powerBtn.textContent = "Toggle Power";
|
||||
powerHint.textContent = "State unknown";
|
||||
}
|
||||
lastControl = update.enabled;
|
||||
|
||||
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
|
||||
txLimitInput.value = update.status.tx.limit;
|
||||
txLimitRow.style.display = "";
|
||||
} else {
|
||||
txLimitInput.value = "";
|
||||
txLimitRow.style.display = "none";
|
||||
}
|
||||
|
||||
powerHint.textContent = "Ready";
|
||||
const locked = update.status && update.status.lock === true;
|
||||
lockBtn.textContent = locked ? "Unlock" : "Lock";
|
||||
|
||||
const tx = update.status && update.status.tx ? update.status.tx : null;
|
||||
txMeters.style.display = "";
|
||||
if (tx && typeof tx.power === "number") {
|
||||
const pct = Math.max(0, Math.min(100, tx.power));
|
||||
pwrBar.style.width = `${pct}%`;
|
||||
pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`;
|
||||
} else {
|
||||
pwrBar.style.width = "0%";
|
||||
pwrValue.textContent = "PWR --";
|
||||
}
|
||||
if (tx && typeof tx.swr === "number") {
|
||||
const swr = Math.max(1, tx.swr);
|
||||
const pct = Math.max(0, Math.min(100, ((swr - 1) / 2) * 100));
|
||||
swrBar.style.width = `${pct}%`;
|
||||
swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`;
|
||||
} else {
|
||||
swrBar.style.width = "0%";
|
||||
swrValue.textContent = "SWR --";
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
if (esHeartbeat) {
|
||||
clearInterval(esHeartbeat);
|
||||
}
|
||||
es = new EventSource("/events");
|
||||
lastEventAt = Date.now();
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
if (evt.data === lastRendered) return;
|
||||
const data = JSON.parse(evt.data);
|
||||
lastRendered = evt.data;
|
||||
render(data);
|
||||
lastEventAt = Date.now();
|
||||
if (data.initialized) {
|
||||
powerHint.textContent = "Ready";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Bad event data", e);
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
powerHint.textContent = "Disconnected, retrying…";
|
||||
es.close();
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
|
||||
esHeartbeat = setInterval(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastEventAt > 8000) {
|
||||
es.close();
|
||||
connect();
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function postPath(path) {
|
||||
const resp = await fetch(path, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text || resp.statusText);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
powerBtn.addEventListener("click", async () => {
|
||||
powerBtn.disabled = true;
|
||||
powerHint.textContent = "Sending...";
|
||||
try {
|
||||
await postPath("/toggle_power");
|
||||
powerHint.textContent = "Toggled, waiting for update…";
|
||||
} catch (err) {
|
||||
powerHint.textContent = "Toggle failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
powerBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
vfoBtn.addEventListener("click", async () => {
|
||||
vfoBtn.disabled = true;
|
||||
powerHint.textContent = "Toggling VFO…";
|
||||
try {
|
||||
await postPath("/toggle_vfo");
|
||||
powerHint.textContent = "VFO toggled, waiting for update…";
|
||||
setTimeout(() => {
|
||||
if (powerHint.textContent.includes("VFO toggled")) {
|
||||
powerHint.textContent = "Ready";
|
||||
}
|
||||
}, 1200);
|
||||
} catch (err) {
|
||||
powerHint.textContent = "VFO toggle failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
vfoBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
pttBtn.addEventListener("click", async () => {
|
||||
pttBtn.disabled = true;
|
||||
powerHint.textContent = "Toggling PTT…";
|
||||
try {
|
||||
const desired = lastTxEn ? "false" : "true";
|
||||
await postPath(`/set_ptt?ptt=${desired}`);
|
||||
powerHint.textContent = "PTT command sent";
|
||||
} catch (err) {
|
||||
powerHint.textContent = "PTT toggle failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
pttBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
freqBtn.addEventListener("click", async () => {
|
||||
const parsed = parseFreqInput(freqEl.value);
|
||||
if (parsed === null) {
|
||||
powerHint.textContent = "Freq missing";
|
||||
return;
|
||||
}
|
||||
if (!freqAllowed(parsed)) {
|
||||
powerHint.textContent = "Out of supported bands";
|
||||
setTimeout(() => powerHint.textContent = "Ready", 1500);
|
||||
return;
|
||||
}
|
||||
freqDirty = false;
|
||||
freqBtn.disabled = true;
|
||||
powerHint.textContent = "Setting frequency…";
|
||||
try {
|
||||
await postPath(`/set_freq?hz=${parsed}`);
|
||||
powerHint.textContent = "Freq set";
|
||||
} catch (err) {
|
||||
powerHint.textContent = "Set freq failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
freqBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
freqEl.addEventListener("keydown", (e) => {
|
||||
freqDirty = true;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
freqBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
modeBtn.addEventListener("click", async () => {
|
||||
const mode = modeEl.value || "";
|
||||
if (!mode) {
|
||||
powerHint.textContent = "Mode missing";
|
||||
return;
|
||||
}
|
||||
modeDirty = false;
|
||||
modeBtn.disabled = true;
|
||||
powerHint.textContent = "Setting mode…";
|
||||
try {
|
||||
await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`);
|
||||
powerHint.textContent = "Mode set";
|
||||
} catch (err) {
|
||||
powerHint.textContent = "Set mode failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
modeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modeEl.addEventListener("input", () => {
|
||||
modeDirty = true;
|
||||
});
|
||||
|
||||
txLimitBtn.addEventListener("click", async () => {
|
||||
const limit = txLimitInput.value;
|
||||
if (limit === "" || limit === "--") {
|
||||
powerHint.textContent = "Limit missing";
|
||||
return;
|
||||
}
|
||||
txLimitBtn.disabled = true;
|
||||
powerHint.textContent = "Setting TX limit…";
|
||||
try {
|
||||
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
||||
powerHint.textContent = "TX limit set";
|
||||
} catch (err) {
|
||||
powerHint.textContent = "TX limit failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
txLimitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
lockBtn.disabled = true;
|
||||
powerHint.textContent = "Toggling lock…";
|
||||
try {
|
||||
const nextLock = lockBtn.textContent === "Lock";
|
||||
await postPath(nextLock ? "/lock" : "/unlock");
|
||||
powerHint.textContent = "Lock toggled";
|
||||
} catch (err) {
|
||||
powerHint.textContent = "Lock toggle failed";
|
||||
console.error(err);
|
||||
setTimeout(() => powerHint.textContent = "Ready", 2000);
|
||||
} finally {
|
||||
lockBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
@@ -0,0 +1,24 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-qt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
qt = ["dep:qmetaobject"]
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
qmetaobject = { version = "0.2", optional = true }
|
||||
@@ -0,0 +1,36 @@
|
||||
# Qt QML Frontend Requirements
|
||||
|
||||
## Scope
|
||||
- Provide a Qt Quick (QML) GUI frontend for trx-rs.
|
||||
- Linux-only support for the initial implementation.
|
||||
- Use system-wide Qt6 (no vendored Qt).
|
||||
- Frontend must be optional and feature-gated; default build should not require Qt.
|
||||
- Feature name in `trx-bin`: `qt-frontend`.
|
||||
|
||||
## Functional Requirements
|
||||
- Show rig status: frequency, mode, PTT state, VFO info, lock state, power state.
|
||||
- Show basic meters when available: RX signal, TX power/limit/SWR/ALC (as provided by state).
|
||||
- Allow commands: set frequency, set mode, toggle PTT, power on/off, toggle VFO, lock/unlock, set TX limit (if supported).
|
||||
- Reflect live updates pushed from the rig task (watch updates).
|
||||
|
||||
## Non-Functional Requirements
|
||||
- Linux-only for now.
|
||||
- Build relies on Qt6 libraries/headers installed on the system.
|
||||
- GUI must be responsive and not block the rig task or frontend thread.
|
||||
- Minimal but clear UI; no advanced theming or custom widgets required yet.
|
||||
|
||||
## Configuration & Integration
|
||||
- Expose as a new frontend crate: `trx-frontend-qt`.
|
||||
- Register via frontend registry under name: `qt`.
|
||||
- Optional via feature flag (e.g., `qt`) and not part of default workspace features.
|
||||
- Provide config toggles under `[frontends.qt]` for enable/listen if needed.
|
||||
- Remote client mode uses JSON TCP with bearer token via `frontends.qt.remote.*`.
|
||||
|
||||
## Packaging/Build
|
||||
- Document required packages (Qt6 base + QML modules + qmetaobject-rs build prereqs).
|
||||
- Provide build/run instructions in README/OVERVIEW updates.
|
||||
|
||||
## Out of Scope (for v1)
|
||||
- Windows/macOS support.
|
||||
- Offline themes or custom QML assets.
|
||||
- Advanced settings editor or multi-rig management.
|
||||
@@ -0,0 +1,102 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
visible: true
|
||||
width: 900
|
||||
height: 540
|
||||
title: "trx-rs"
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
|
||||
Label {
|
||||
text: "trx-rs Qt frontend (stub)"
|
||||
font.pixelSize: 20
|
||||
}
|
||||
|
||||
Label { text: "Frequency: " + rig.freq_text + " (" + rig.freq_hz + " Hz)" }
|
||||
Label { text: "Mode: " + rig.mode + " Band: " + rig.band }
|
||||
Label { text: "PTT: " + (rig.tx_enabled ? "TX" : "RX") + " Power: " + (rig.powered ? "On" : "Off") }
|
||||
Label { text: "Lock: " + (rig.locked ? "Locked" : "Unlocked") }
|
||||
Label { text: "RX Sig: " + rig.rx_sig + " dB" }
|
||||
Label { text: "TX Pwr: " + rig.tx_power + " Limit: " + rig.tx_limit + " SWR: " + rig.tx_swr + " ALC: " + rig.tx_alc }
|
||||
|
||||
Row {
|
||||
spacing: 6
|
||||
|
||||
TextField {
|
||||
id: freqInput
|
||||
width: 140
|
||||
placeholderText: "Freq (Hz)"
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Set Freq"
|
||||
onClicked: rig.set_freq_hz(parseInt(freqInput.text))
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: modeInput
|
||||
width: 80
|
||||
placeholderText: "Mode"
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Set Mode"
|
||||
onClicked: rig.set_mode(modeInput.text)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 6
|
||||
|
||||
Button {
|
||||
text: rig.tx_enabled ? "PTT Off" : "PTT On"
|
||||
onClicked: rig.toggle_ptt()
|
||||
}
|
||||
Button {
|
||||
text: rig.powered ? "Power Off" : "Power On"
|
||||
onClicked: rig.toggle_power()
|
||||
}
|
||||
Button {
|
||||
text: "VFO"
|
||||
onClicked: rig.toggle_vfo()
|
||||
}
|
||||
Button {
|
||||
text: rig.locked ? "Unlock" : "Lock"
|
||||
onClicked: rig.locked ? rig.unlock_panel() : rig.lock_panel()
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 6
|
||||
TextField {
|
||||
id: txLimitInput
|
||||
width: 120
|
||||
placeholderText: "TX Limit"
|
||||
}
|
||||
Button {
|
||||
text: "Set Limit"
|
||||
onClicked: rig.set_tx_limit(parseInt(txLimitInput.text))
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 540
|
||||
height: 120
|
||||
color: "#20252b"
|
||||
radius: 6
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
color: "#d0d6de"
|
||||
text: rig.vfo
|
||||
font.family: "monospace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "qt"))]
|
||||
pub mod server;
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "qt"))]
|
||||
pub fn register_frontend() {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
trx_frontend::register_frontend("qt", server::QtFrontend::spawn_frontend);
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "linux", feature = "qt")))]
|
||||
pub fn register_frontend() {
|
||||
// No-op on non-Linux platforms.
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
|
||||
use qmetaobject::{
|
||||
qt_base_class, qt_method, qt_property, qt_signal, queued_callback, QObject, QObjectPinned,
|
||||
QString, QmlEngine,
|
||||
};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use trx_core::rig::command::RigCommand;
|
||||
use trx_core::rig::state::RigMode;
|
||||
use trx_core::{RigRequest, RigState};
|
||||
use trx_frontend::FrontendSpawner;
|
||||
|
||||
/// Qt/QML frontend (Linux-only).
|
||||
pub struct QtFrontend;
|
||||
|
||||
impl FrontendSpawner for QtFrontend {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let (update_tx, update_rx) = oneshot::channel::<Box<dyn Fn(RigState) + Send + Sync>>();
|
||||
|
||||
spawn_qt_thread(update_tx, listen_addr, rig_tx);
|
||||
spawn_state_watcher(state_rx, update_rx).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_qt_thread(
|
||||
update_tx: oneshot::Sender<Box<dyn Fn(RigState) + Send + Sync>>,
|
||||
listen_addr: SocketAddr,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let model_cell = Box::leak(Box::new(RefCell::new(RigStateModel::default())));
|
||||
let model_ptr = model_cell.as_ptr();
|
||||
model_cell.borrow_mut().rig_tx = Some(rig_tx);
|
||||
|
||||
let update = queued_callback(move |state: RigState| unsafe {
|
||||
// Safe as queued_callback executes on the Qt thread where the model lives.
|
||||
let model_cell = &mut *model_ptr;
|
||||
update_model(model_cell, &state);
|
||||
});
|
||||
|
||||
if update_tx.send(Box::new(update)).is_err() {
|
||||
warn!("Qt frontend update channel dropped before init");
|
||||
}
|
||||
|
||||
let mut engine = QmlEngine::new();
|
||||
engine.set_object_property("rig".into(), unsafe { QObjectPinned::new(model_cell) });
|
||||
|
||||
let qml_path = qml_main_path();
|
||||
info!("Qt frontend loading QML from {}", qml_path.display());
|
||||
engine.load_file(QString::from(qml_path.to_string_lossy().to_string()));
|
||||
info!("Qt frontend running (addr hint: {})", listen_addr);
|
||||
engine.exec();
|
||||
});
|
||||
}
|
||||
|
||||
async fn spawn_state_watcher(
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
update_rx: oneshot::Receiver<Box<dyn Fn(RigState) + Send + Sync>>,
|
||||
) {
|
||||
let Ok(update) = update_rx.await else {
|
||||
warn!("Qt frontend update channel closed");
|
||||
return;
|
||||
};
|
||||
|
||||
update(state_rx.borrow().clone());
|
||||
while state_rx.changed().await.is_ok() {
|
||||
update(state_rx.borrow().clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn qml_main_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("qml")
|
||||
.join("Main.qml")
|
||||
}
|
||||
|
||||
#[derive(QObject, Default)]
|
||||
struct RigStateModel {
|
||||
base: qt_base_class!(trait QObject),
|
||||
rig_tx: Option<mpsc::Sender<RigRequest>>,
|
||||
freq_hz: qt_property!(u64; NOTIFY freq_hz_changed),
|
||||
freq_hz_changed: qt_signal!(),
|
||||
freq_text: qt_property!(QString; NOTIFY freq_text_changed),
|
||||
freq_text_changed: qt_signal!(),
|
||||
mode: qt_property!(QString; NOTIFY mode_changed),
|
||||
mode_changed: qt_signal!(),
|
||||
band: qt_property!(QString; NOTIFY band_changed),
|
||||
band_changed: qt_signal!(),
|
||||
tx_enabled: qt_property!(bool; NOTIFY tx_enabled_changed),
|
||||
tx_enabled_changed: qt_signal!(),
|
||||
locked: qt_property!(bool; NOTIFY locked_changed),
|
||||
locked_changed: qt_signal!(),
|
||||
powered: qt_property!(bool; NOTIFY powered_changed),
|
||||
powered_changed: qt_signal!(),
|
||||
rx_sig: qt_property!(i32; NOTIFY rx_sig_changed),
|
||||
rx_sig_changed: qt_signal!(),
|
||||
tx_power: qt_property!(i32; NOTIFY tx_power_changed),
|
||||
tx_power_changed: qt_signal!(),
|
||||
tx_limit: qt_property!(i32; NOTIFY tx_limit_changed),
|
||||
tx_limit_changed: qt_signal!(),
|
||||
tx_swr: qt_property!(f64; NOTIFY tx_swr_changed),
|
||||
tx_swr_changed: qt_signal!(),
|
||||
tx_alc: qt_property!(i32; NOTIFY tx_alc_changed),
|
||||
tx_alc_changed: qt_signal!(),
|
||||
vfo: qt_property!(QString; NOTIFY vfo_changed),
|
||||
vfo_changed: qt_signal!(),
|
||||
set_freq_hz: qt_method!(
|
||||
fn set_freq_hz(&self, hz: i64) {
|
||||
if hz <= 0 {
|
||||
return;
|
||||
}
|
||||
self.send_command(RigCommand::SetFreq(trx_core::radio::freq::Freq {
|
||||
hz: hz as u64,
|
||||
}));
|
||||
}
|
||||
),
|
||||
set_mode: qt_method!(
|
||||
fn set_mode(&self, mode: QString) {
|
||||
let mode = parse_mode(&mode.to_string());
|
||||
self.send_command(RigCommand::SetMode(mode));
|
||||
}
|
||||
),
|
||||
toggle_ptt: qt_method!(
|
||||
fn toggle_ptt(&self) {
|
||||
self.send_command(RigCommand::SetPtt(!self.tx_enabled));
|
||||
}
|
||||
),
|
||||
toggle_power: qt_method!(
|
||||
fn toggle_power(&self) {
|
||||
if self.powered {
|
||||
self.send_command(RigCommand::PowerOff);
|
||||
} else {
|
||||
self.send_command(RigCommand::PowerOn);
|
||||
}
|
||||
}
|
||||
),
|
||||
toggle_vfo: qt_method!(
|
||||
fn toggle_vfo(&self) {
|
||||
self.send_command(RigCommand::ToggleVfo);
|
||||
}
|
||||
),
|
||||
lock_panel: qt_method!(
|
||||
fn lock_panel(&self) {
|
||||
self.send_command(RigCommand::Lock);
|
||||
}
|
||||
),
|
||||
unlock_panel: qt_method!(
|
||||
fn unlock_panel(&self) {
|
||||
self.send_command(RigCommand::Unlock);
|
||||
}
|
||||
),
|
||||
set_tx_limit: qt_method!(
|
||||
fn set_tx_limit(&self, limit: i32) {
|
||||
if limit < 0 {
|
||||
return;
|
||||
}
|
||||
self.send_command(RigCommand::SetTxLimit(limit as u8));
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
impl RigStateModel {
|
||||
fn send_command(&self, cmd: RigCommand) {
|
||||
let Some(tx) = self.rig_tx.as_ref() else {
|
||||
warn!("Qt frontend: rig command dropped (channel not set)");
|
||||
return;
|
||||
};
|
||||
|
||||
let (resp_tx, _resp_rx) = oneshot::channel();
|
||||
if tx
|
||||
.blocking_send(RigRequest {
|
||||
cmd,
|
||||
respond_to: resp_tx,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
warn!("Qt frontend: rig command send failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_model(model: &mut RigStateModel, state: &RigState) {
|
||||
let freq_hz = state.status.freq.hz;
|
||||
if model.freq_hz != freq_hz {
|
||||
model.freq_hz = freq_hz;
|
||||
model.freq_hz_changed();
|
||||
}
|
||||
|
||||
let freq_text = QString::from(format_freq(freq_hz));
|
||||
if model.freq_text != freq_text {
|
||||
model.freq_text = freq_text;
|
||||
model.freq_text_changed();
|
||||
}
|
||||
|
||||
let mode = QString::from(mode_label(&state.status.mode));
|
||||
if model.mode != mode {
|
||||
model.mode = mode;
|
||||
model.mode_changed();
|
||||
}
|
||||
|
||||
let band = QString::from(state.band_name().unwrap_or_else(|| "--".to_string()));
|
||||
if model.band != band {
|
||||
model.band = band;
|
||||
model.band_changed();
|
||||
}
|
||||
|
||||
if model.tx_enabled != state.status.tx_en {
|
||||
model.tx_enabled = state.status.tx_en;
|
||||
model.tx_enabled_changed();
|
||||
}
|
||||
|
||||
let locked = state.status.lock.unwrap_or(false);
|
||||
if model.locked != locked {
|
||||
model.locked = locked;
|
||||
model.locked_changed();
|
||||
}
|
||||
|
||||
let powered = state.control.enabled.unwrap_or(false);
|
||||
if model.powered != powered {
|
||||
model.powered = powered;
|
||||
model.powered_changed();
|
||||
}
|
||||
|
||||
let rx_sig = state.status.rx.as_ref().and_then(|rx| rx.sig).unwrap_or(0);
|
||||
if model.rx_sig != rx_sig {
|
||||
model.rx_sig = rx_sig;
|
||||
model.rx_sig_changed();
|
||||
}
|
||||
|
||||
let tx_power = state
|
||||
.status
|
||||
.tx
|
||||
.as_ref()
|
||||
.and_then(|tx| tx.power)
|
||||
.map(i32::from)
|
||||
.unwrap_or(0);
|
||||
if model.tx_power != tx_power {
|
||||
model.tx_power = tx_power;
|
||||
model.tx_power_changed();
|
||||
}
|
||||
|
||||
let tx_limit = state
|
||||
.status
|
||||
.tx
|
||||
.as_ref()
|
||||
.and_then(|tx| tx.limit)
|
||||
.map(i32::from)
|
||||
.unwrap_or(0);
|
||||
if model.tx_limit != tx_limit {
|
||||
model.tx_limit = tx_limit;
|
||||
model.tx_limit_changed();
|
||||
}
|
||||
|
||||
let tx_swr = state
|
||||
.status
|
||||
.tx
|
||||
.as_ref()
|
||||
.and_then(|tx| tx.swr)
|
||||
.unwrap_or(0.0) as f64;
|
||||
if (model.tx_swr - tx_swr).abs() > f64::EPSILON {
|
||||
model.tx_swr = tx_swr;
|
||||
model.tx_swr_changed();
|
||||
}
|
||||
|
||||
let tx_alc = state
|
||||
.status
|
||||
.tx
|
||||
.as_ref()
|
||||
.and_then(|tx| tx.alc)
|
||||
.map(i32::from)
|
||||
.unwrap_or(0);
|
||||
if model.tx_alc != tx_alc {
|
||||
model.tx_alc = tx_alc;
|
||||
model.tx_alc_changed();
|
||||
}
|
||||
|
||||
let vfo = QString::from(vfo_label(state));
|
||||
if model.vfo != vfo {
|
||||
model.vfo = vfo;
|
||||
model.vfo_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn format_freq(hz: u64) -> String {
|
||||
if hz >= 1_000_000_000 {
|
||||
format!("{:.3} GHz", hz as f64 / 1_000_000_000.0)
|
||||
} else if hz >= 10_000_000 {
|
||||
format!("{:.3} MHz", hz as f64 / 1_000_000.0)
|
||||
} else if hz >= 1_000 {
|
||||
format!("{:.1} kHz", hz as f64 / 1_000.0)
|
||||
} else {
|
||||
format!("{hz} Hz")
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_label(mode: &RigMode) -> String {
|
||||
match mode {
|
||||
RigMode::LSB => "LSB".to_string(),
|
||||
RigMode::USB => "USB".to_string(),
|
||||
RigMode::CW => "CW".to_string(),
|
||||
RigMode::CWR => "CWR".to_string(),
|
||||
RigMode::AM => "AM".to_string(),
|
||||
RigMode::WFM => "WFM".to_string(),
|
||||
RigMode::FM => "FM".to_string(),
|
||||
RigMode::DIG => "DIG".to_string(),
|
||||
RigMode::PKT => "PKT".to_string(),
|
||||
RigMode::Other(val) => val.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mode(value: &str) -> RigMode {
|
||||
match value.trim().to_uppercase().as_str() {
|
||||
"LSB" => RigMode::LSB,
|
||||
"USB" => RigMode::USB,
|
||||
"CW" => RigMode::CW,
|
||||
"CWR" => RigMode::CWR,
|
||||
"AM" => RigMode::AM,
|
||||
"FM" => RigMode::FM,
|
||||
"WFM" => RigMode::WFM,
|
||||
"DIG" | "DIGI" => RigMode::DIG,
|
||||
"PKT" | "PACKET" => RigMode::PKT,
|
||||
other => RigMode::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn vfo_label(state: &RigState) -> String {
|
||||
let Some(vfo) = state.status.vfo.as_ref() else {
|
||||
return "--".to_string();
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for (idx, entry) in vfo.entries.iter().enumerate() {
|
||||
let marker = if vfo.active == Some(idx) { "*" } else { " " };
|
||||
let freq = format_freq(entry.freq.hz);
|
||||
let mode = entry
|
||||
.mode
|
||||
.as_ref()
|
||||
.map(mode_label)
|
||||
.unwrap_or_else(|| "--".to_string());
|
||||
lines.push(format!("{marker} {}: {} {}", entry.name, freq, mode));
|
||||
}
|
||||
lines.join("\\n")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-rigctl"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub fn register_frontend() {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
trx_frontend::register_frontend("rigctl", server::RigctlFrontend::spawn_frontend);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::state::RigSnapshot;
|
||||
use trx_core::{RigCommand, RigMode, RigRequest, RigState};
|
||||
use trx_frontend::FrontendSpawner;
|
||||
|
||||
/// rigctl-compatible frontend.
|
||||
///
|
||||
/// This exposes a small subset of the rigctl/rigctld ASCII protocol to allow
|
||||
/// existing tooling to drive the rig. The implementation is intentionally
|
||||
/// minimal and only covers the operations supported by the core rig task.
|
||||
pub struct RigctlFrontend;
|
||||
|
||||
impl FrontendSpawner for RigctlFrontend {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(listen_addr, state_rx, rig_tx).await {
|
||||
error!("rigctl server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
listen_addr: SocketAddr,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
) -> std::io::Result<()> {
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
info!("rigctl frontend listening on {}", listen_addr);
|
||||
info!("rigctl frontend ready (rigctld-compatible)");
|
||||
|
||||
loop {
|
||||
let (stream, addr) = listener.accept().await?;
|
||||
info!("rigctl client connected: {}", addr);
|
||||
let state_rx = state_rx.clone();
|
||||
let rig_tx = rig_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(stream, addr, state_rx, rig_tx).await {
|
||||
warn!("rigctl client {} error: {:?}", addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
stream: TcpStream,
|
||||
addr: SocketAddr,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
) -> std::io::Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes_read = reader.read_line(&mut line).await?;
|
||||
if bytes_read == 0 {
|
||||
debug!("rigctl client {} disconnected", addr);
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match process_command(trimmed, &mut state_rx, &rig_tx).await {
|
||||
CommandResult::Reply(resp) => writer.write_all(resp.as_bytes()).await?,
|
||||
CommandResult::Close => break,
|
||||
}
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum CommandResult {
|
||||
Reply(String),
|
||||
Close,
|
||||
}
|
||||
|
||||
async fn process_command(
|
||||
cmd_line: &str,
|
||||
state_rx: &mut watch::Receiver<RigState>,
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
) -> CommandResult {
|
||||
let mut parts = cmd_line.split_whitespace();
|
||||
let Some(op) = parts.next() else {
|
||||
return CommandResult::Reply(err_response("empty command"));
|
||||
};
|
||||
|
||||
let resp = match op {
|
||||
"q" | "Q" | "\\q" | "\\quit" => return CommandResult::Close,
|
||||
"f" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response([snapshot.status.freq.hz.to_string()]),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"F" => match parts.next().and_then(|s| s.parse::<u64>().ok()) {
|
||||
Some(freq) => {
|
||||
match send_rig_command(rig_tx, RigCommand::SetFreq(Freq { hz: freq })).await {
|
||||
Ok(_) => ok_only(),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
None => err_response("expected frequency in Hz"),
|
||||
},
|
||||
"m" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => {
|
||||
let mode = rig_mode_to_str(&snapshot.status.mode);
|
||||
ok_response([mode, "0".to_string()])
|
||||
}
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"M" => {
|
||||
let Some(mode_str) = parts.next() else {
|
||||
return CommandResult::Reply(err_response("expected mode"));
|
||||
};
|
||||
let mode = parse_mode(mode_str);
|
||||
match send_rig_command(rig_tx, RigCommand::SetMode(mode)).await {
|
||||
Ok(_) => ok_only(),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
"t" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => {
|
||||
ok_response([if snapshot.status.tx_en { "1" } else { "0" }.to_string()])
|
||||
}
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"T" => match parts.next() {
|
||||
Some(v) if is_true(v) => match send_rig_command(rig_tx, RigCommand::SetPtt(true)).await
|
||||
{
|
||||
Ok(_) => ok_only(),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
Some(v) if is_false(v) => {
|
||||
match send_rig_command(rig_tx, RigCommand::SetPtt(false)).await {
|
||||
Ok(_) => ok_only(),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
_ => err_response("expected PTT state (0/1)"),
|
||||
},
|
||||
"\\get_powerstat" | "get_powerstat" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => {
|
||||
let val = snapshot.enabled.unwrap_or(false);
|
||||
ok_response([if val { "1" } else { "0" }.to_string()])
|
||||
}
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"\\chk_vfo" | "chk_vfo" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response([active_vfo_label(&snapshot)]),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"\\dump_state" | "dump_state" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response(dump_state_lines(&snapshot)),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"i" | "I" => {
|
||||
let snapshot = match current_snapshot(state_rx) {
|
||||
Some(s) => s,
|
||||
None => match request_snapshot(rig_tx).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return CommandResult::Reply(err_response(&e)),
|
||||
},
|
||||
};
|
||||
let info_line = format!("{} {}", snapshot.info.manufacturer, snapshot.info.model);
|
||||
ok_response([info_line])
|
||||
}
|
||||
_ => {
|
||||
warn!("rigctl unsupported command: {}", cmd_line);
|
||||
err_response("unsupported command")
|
||||
}
|
||||
};
|
||||
|
||||
CommandResult::Reply(resp)
|
||||
}
|
||||
|
||||
fn ok_response<I, S>(lines: I) -> String
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let mut resp = String::new();
|
||||
for line in lines {
|
||||
let line = line.into();
|
||||
if !line.is_empty() {
|
||||
resp.push_str(&line);
|
||||
resp.push('\n');
|
||||
}
|
||||
}
|
||||
resp.push_str("RPRT 0\n");
|
||||
resp
|
||||
}
|
||||
|
||||
fn ok_only() -> String {
|
||||
"RPRT 0\n".to_string()
|
||||
}
|
||||
|
||||
fn err_response(msg: &str) -> String {
|
||||
warn!("rigctl command error: {}", msg);
|
||||
"RPRT -1\n".to_string()
|
||||
}
|
||||
|
||||
async fn request_snapshot(rig_tx: &mpsc::Sender<RigRequest>) -> Result<RigSnapshot, String> {
|
||||
send_rig_command(rig_tx, RigCommand::GetSnapshot).await
|
||||
}
|
||||
|
||||
async fn send_rig_command(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
cmd: RigCommand,
|
||||
) -> Result<RigSnapshot, String> {
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
rig_tx
|
||||
.send(RigRequest {
|
||||
cmd,
|
||||
respond_to: resp_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("failed to send to rig: {e:?}"))?;
|
||||
|
||||
match timeout(Duration::from_secs(5), resp_rx).await {
|
||||
Ok(Ok(Ok(snapshot))) => Ok(snapshot),
|
||||
Ok(Ok(Err(err))) => Err(err.message),
|
||||
Ok(Err(e)) => Err(format!("rig response error: {e:?}")),
|
||||
Err(_) => Err("rig response timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_snapshot(state_rx: &watch::Receiver<RigState>) -> Option<RigSnapshot> {
|
||||
state_rx.borrow().snapshot()
|
||||
}
|
||||
|
||||
fn parse_mode(s: &str) -> RigMode {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"LSB" => RigMode::LSB,
|
||||
"USB" => RigMode::USB,
|
||||
"CW" => RigMode::CW,
|
||||
"CWR" => RigMode::CWR,
|
||||
"AM" => RigMode::AM,
|
||||
"FM" => RigMode::FM,
|
||||
"WFM" => RigMode::WFM,
|
||||
"DIG" | "DIGI" => RigMode::DIG,
|
||||
"PKT" | "PACKET" => RigMode::PKT,
|
||||
other => RigMode::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rig_mode_to_str(mode: &RigMode) -> String {
|
||||
match mode {
|
||||
RigMode::Other(other) => other.clone(),
|
||||
other => format!("{:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_state_lines(_snapshot: &RigSnapshot) -> Vec<String> {
|
||||
// Hamlib expects a long, fixed sequence of bare values.
|
||||
// To maximize compatibility, mirror the ordering produced by hamlib's dummy backend.
|
||||
vec![
|
||||
"1".to_string(),
|
||||
"1".to_string(),
|
||||
"0".to_string(),
|
||||
"150000.000000 1500000000.000000 0x1ff -1 -1 0x17e00007 0xf".to_string(),
|
||||
"0 0 0 0 0 0 0".to_string(),
|
||||
"150000.000000 1500000000.000000 0x1ff 5000 100000 0x17e00007 0xf".to_string(),
|
||||
"0 0 0 0 0 0 0".to_string(),
|
||||
"0x1ff 1".to_string(),
|
||||
"0x1ff 0".to_string(),
|
||||
"0 0".to_string(),
|
||||
"0xc 2400".to_string(),
|
||||
"0xc 1800".to_string(),
|
||||
"0xc 3000".to_string(),
|
||||
"0xc 0".to_string(),
|
||||
"0x2 500".to_string(),
|
||||
"0x2 2400".to_string(),
|
||||
"0x2 50".to_string(),
|
||||
"0x2 0".to_string(),
|
||||
"0x10 300".to_string(),
|
||||
"0x10 2400".to_string(),
|
||||
"0x10 50".to_string(),
|
||||
"0x10 0".to_string(),
|
||||
"0x1 8000".to_string(),
|
||||
"0x1 2400".to_string(),
|
||||
"0x1 10000".to_string(),
|
||||
"0x20 15000".to_string(),
|
||||
"0x20 8000".to_string(),
|
||||
"0x40 230000".to_string(),
|
||||
"0 0".to_string(),
|
||||
"9990".to_string(),
|
||||
"9990".to_string(),
|
||||
"10000".to_string(),
|
||||
"0".to_string(),
|
||||
"10 ".to_string(),
|
||||
"10 20 30 ".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
"0xfffffffff7ffffff".to_string(),
|
||||
"0xfffeff7083ffffff".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
"0xffffffffffffffbf".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn active_vfo_label(snapshot: &RigSnapshot) -> String {
|
||||
// Normalize to VFOA/VFOB/... for hamlib compatibility.
|
||||
snapshot
|
||||
.status
|
||||
.vfo
|
||||
.as_ref()
|
||||
.and_then(|vfo| vfo.active)
|
||||
.map(|idx| {
|
||||
let letter = (b'A' + (idx as u8)) as char;
|
||||
format!("VFO{}", letter)
|
||||
})
|
||||
.unwrap_or_else(|| "VFOA".to_string())
|
||||
}
|
||||
fn is_true(s: &str) -> bool {
|
||||
matches!(s, "1" | "on" | "ON" | "true" | "True" | "TRUE")
|
||||
}
|
||||
|
||||
fn is_false(s: &str) -> bool {
|
||||
matches!(s, "0" | "off" | "OFF" | "false" | "False" | "FALSE")
|
||||
}
|
||||
Reference in New Issue
Block a user