From 74d06e7a7c3fb7c2fc5ed565920f052a817b22db Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sun, 18 Jan 2026 09:23:25 +0100 Subject: [PATCH] frontend: add new qt, rigctl, and json frontends --- .../src/trx-frontend-http-json/Cargo.toml | 15 + .../src/trx-frontend-http-json/src/lib.rs | 14 + .../src/trx-frontend-http-json/src/server.rs | 254 ++++++++++++ .../src/trx-frontend-http/src/lib.rs | 5 + .../src/trx-frontend-http/src/server.rs | 12 +- .../src/trx-frontend-qt/Cargo.toml | 24 ++ .../src/trx-frontend-qt/REQUIREMENTS.md | 36 ++ .../src/trx-frontend-qt/qml/Main.qml | 102 +++++ .../src/trx-frontend-qt/src/lib.rs | 17 + .../src/trx-frontend-qt/src/server.rs | 360 ++++++++++++++++++ .../src/trx-frontend-rigctl/Cargo.toml | 14 + .../src/trx-frontend-rigctl/src/lib.rs | 10 + .../src/trx-frontend-rigctl/src/server.rs | 343 +++++++++++++++++ 13 files changed, 1202 insertions(+), 4 deletions(-) create mode 100644 src/trx-frontend/src/trx-frontend-http-json/Cargo.toml create mode 100644 src/trx-frontend/src/trx-frontend-http-json/src/lib.rs create mode 100644 src/trx-frontend/src/trx-frontend-http-json/src/server.rs create mode 100644 src/trx-frontend/src/trx-frontend-qt/Cargo.toml create mode 100644 src/trx-frontend/src/trx-frontend-qt/REQUIREMENTS.md create mode 100644 src/trx-frontend/src/trx-frontend-qt/qml/Main.qml create mode 100644 src/trx-frontend/src/trx-frontend-qt/src/lib.rs create mode 100644 src/trx-frontend/src/trx-frontend-qt/src/server.rs create mode 100644 src/trx-frontend/src/trx-frontend-rigctl/Cargo.toml create mode 100644 src/trx-frontend/src/trx-frontend-rigctl/src/lib.rs create mode 100644 src/trx-frontend/src/trx-frontend-rigctl/src/server.rs diff --git a/src/trx-frontend/src/trx-frontend-http-json/Cargo.toml b/src/trx-frontend/src/trx-frontend-http-json/Cargo.toml new file mode 100644 index 0000000..d908f0f --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-http-json/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# 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 } diff --git a/src/trx-frontend/src/trx-frontend-http-json/src/lib.rs b/src/trx-frontend/src/trx-frontend-http-json/src/lib.rs new file mode 100644 index 0000000..69d0ac8 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-http-json/src/lib.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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) { + server::set_auth_tokens(tokens); +} diff --git a/src/trx-frontend/src/trx-frontend-http-json/src/server.rs b/src/trx-frontend/src/trx-frontend-http-json/src/server.rs new file mode 100644 index 0000000..9a600a2 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-http-json/src/server.rs @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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, +} + +fn auth_registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + Mutex::new(AuthConfig { + tokens: HashSet::new(), + }) + }) +} + +pub fn set_auth_tokens(tokens: Vec) { + 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, + rig_tx: mpsc::Sender, + _callsign: Option, + 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) -> 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, +) -> 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 { + match serde_json::from_str::(input) { + Ok(envelope) => Ok(envelope), + Err(_) => { + let cmd = serde_json::from_str::(input)?; + Ok(ClientEnvelope { token: None, cmd }) + } + } +} + +fn authorize(token: &Option) -> 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 + } +} diff --git a/src/trx-frontend/src/trx-frontend-http/src/lib.rs b/src/trx-frontend/src/trx-frontend-http/src/lib.rs index 9306a09..9a2a7ac 100644 --- a/src/trx-frontend/src/trx-frontend-http/src/lib.rs +++ b/src/trx-frontend/src/trx-frontend-http/src/lib.rs @@ -3,3 +3,8 @@ // 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); +} diff --git a/src/trx-frontend/src/trx-frontend-http/src/server.rs b/src/trx-frontend/src/trx-frontend-http/src/server.rs index 41105b7..7c5edab 100644 --- a/src/trx-frontend/src/trx-frontend-http/src/server.rs +++ b/src/trx-frontend/src/trx-frontend-http/src/server.rs @@ -7,6 +7,8 @@ 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; @@ -26,9 +28,10 @@ impl FrontendSpawner for HttpFrontend { state_rx: watch::Receiver, rig_tx: mpsc::Sender, callsign: Option, + listen_addr: SocketAddr, ) -> JoinHandle<()> { tokio::spawn(async move { - if let Err(e) = serve(state_rx, rig_tx, callsign).await { + if let Err(e) = serve(listen_addr, state_rx, rig_tx, callsign).await { error!("HTTP status server error: {:?}", e); } }) @@ -36,24 +39,25 @@ impl FrontendSpawner for HttpFrontend { } async fn serve( + addr: SocketAddr, state_rx: watch::Receiver, rig_tx: mpsc::Sender, callsign: Option, ) -> Result<(), actix_web::Error> { - let addr = ("127.0.0.1", 8080); 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 status server on {}:{}", addr.0, addr.1); + info!("http frontend listening on {}", addr); + info!("http frontend ready (status/control)"); server.await?; Ok(()) } fn build_server( - addr: (&str, u16), + addr: SocketAddr, state_rx: watch::Receiver, rig_tx: mpsc::Sender, callsign: Option, diff --git a/src/trx-frontend/src/trx-frontend-qt/Cargo.toml b/src/trx-frontend/src/trx-frontend-qt/Cargo.toml new file mode 100644 index 0000000..d85ce16 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-qt/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# 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 } diff --git a/src/trx-frontend/src/trx-frontend-qt/REQUIREMENTS.md b/src/trx-frontend/src/trx-frontend-qt/REQUIREMENTS.md new file mode 100644 index 0000000..be126cc --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-qt/REQUIREMENTS.md @@ -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. diff --git a/src/trx-frontend/src/trx-frontend-qt/qml/Main.qml b/src/trx-frontend/src/trx-frontend-qt/qml/Main.qml new file mode 100644 index 0000000..6f3848d --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-qt/qml/Main.qml @@ -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" + } + } + } +} diff --git a/src/trx-frontend/src/trx-frontend-qt/src/lib.rs b/src/trx-frontend/src/trx-frontend-qt/src/lib.rs new file mode 100644 index 0000000..b2a43ef --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-qt/src/lib.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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. +} diff --git a/src/trx-frontend/src/trx-frontend-qt/src/server.rs b/src/trx-frontend/src/trx-frontend-qt/src/server.rs new file mode 100644 index 0000000..d529591 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-qt/src/server.rs @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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, + rig_tx: mpsc::Sender, + _callsign: Option, + listen_addr: SocketAddr, + ) -> JoinHandle<()> { + tokio::spawn(async move { + let (update_tx, update_rx) = oneshot::channel::>(); + + 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>, + listen_addr: SocketAddr, + rig_tx: mpsc::Sender, +) { + 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, + update_rx: oneshot::Receiver>, +) { + 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>, + 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") +} diff --git a/src/trx-frontend/src/trx-frontend-rigctl/Cargo.toml b/src/trx-frontend/src/trx-frontend-rigctl/Cargo.toml new file mode 100644 index 0000000..00db285 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-rigctl/Cargo.toml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# 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 = "../.." } diff --git a/src/trx-frontend/src/trx-frontend-rigctl/src/lib.rs b/src/trx-frontend/src/trx-frontend-rigctl/src/lib.rs new file mode 100644 index 0000000..cadb289 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-rigctl/src/lib.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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); +} diff --git a/src/trx-frontend/src/trx-frontend-rigctl/src/server.rs b/src/trx-frontend/src/trx-frontend-rigctl/src/server.rs new file mode 100644 index 0000000..4b3ed36 --- /dev/null +++ b/src/trx-frontend/src/trx-frontend-rigctl/src/server.rs @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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, + rig_tx: mpsc::Sender, + _callsign: Option, + 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, + rig_tx: mpsc::Sender, +) -> 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, + rig_tx: mpsc::Sender, +) -> 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, + rig_tx: &mpsc::Sender, +) -> 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::().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(lines: I) -> String +where + I: IntoIterator, + S: Into, +{ + 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) -> Result { + send_rig_command(rig_tx, RigCommand::GetSnapshot).await +} + +async fn send_rig_command( + rig_tx: &mpsc::Sender, + cmd: RigCommand, +) -> Result { + 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) -> Option { + 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 { + // 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") +}