frontend: add new qt, rigctl, and json frontends
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,8 @@
|
|||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
|
pub fn register_frontend() {
|
||||||
|
use trx_frontend::FrontendSpawner;
|
||||||
|
trx_frontend::register_frontend("http", server::HttpFrontend::spawn_frontend);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ mod api;
|
|||||||
#[path = "status.rs"]
|
#[path = "status.rs"]
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use actix_web::dev::Server;
|
use actix_web::dev::Server;
|
||||||
use actix_web::{web, App, HttpServer};
|
use actix_web::{web, App, HttpServer};
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
@@ -26,9 +28,10 @@ impl FrontendSpawner for HttpFrontend {
|
|||||||
state_rx: watch::Receiver<RigState>,
|
state_rx: watch::Receiver<RigState>,
|
||||||
rig_tx: mpsc::Sender<RigRequest>,
|
rig_tx: mpsc::Sender<RigRequest>,
|
||||||
callsign: Option<String>,
|
callsign: Option<String>,
|
||||||
|
listen_addr: SocketAddr,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
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);
|
error!("HTTP status server error: {:?}", e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -36,24 +39,25 @@ impl FrontendSpawner for HttpFrontend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn serve(
|
async fn serve(
|
||||||
|
addr: SocketAddr,
|
||||||
state_rx: watch::Receiver<RigState>,
|
state_rx: watch::Receiver<RigState>,
|
||||||
rig_tx: mpsc::Sender<RigRequest>,
|
rig_tx: mpsc::Sender<RigRequest>,
|
||||||
callsign: Option<String>,
|
callsign: Option<String>,
|
||||||
) -> Result<(), actix_web::Error> {
|
) -> Result<(), actix_web::Error> {
|
||||||
let addr = ("127.0.0.1", 8080);
|
|
||||||
let server = build_server(addr, state_rx, rig_tx, callsign)?;
|
let server = build_server(addr, state_rx, rig_tx, callsign)?;
|
||||||
let handle = server.handle();
|
let handle = server.handle();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = signal::ctrl_c().await;
|
let _ = signal::ctrl_c().await;
|
||||||
handle.stop(false).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?;
|
server.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_server(
|
fn build_server(
|
||||||
addr: (&str, u16),
|
addr: SocketAddr,
|
||||||
state_rx: watch::Receiver<RigState>,
|
state_rx: watch::Receiver<RigState>,
|
||||||
rig_tx: mpsc::Sender<RigRequest>,
|
rig_tx: mpsc::Sender<RigRequest>,
|
||||||
callsign: Option<String>,
|
callsign: Option<String>,
|
||||||
|
|||||||
@@ -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