initial commit
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["ft817"]
|
||||
ft817 = ["dep:trx-backend-ft817"]
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-backend-ft817 = { path = "src/trx-backend-ft817", optional = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-serial = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tracing = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use clap::ValueEnum;
|
||||
use trx_core::rig::RigCat;
|
||||
use trx_core::DynResult;
|
||||
|
||||
#[cfg(feature = "ft817")]
|
||||
use trx_backend_ft817::Ft817;
|
||||
|
||||
/// Supported rig backends selectable at runtime.
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum RigKind {
|
||||
#[cfg(feature = "ft817")]
|
||||
#[value(alias = "ft-817")]
|
||||
Ft817,
|
||||
}
|
||||
|
||||
impl RigKind {
|
||||
pub fn all() -> &'static [RigKind] {
|
||||
&[
|
||||
#[cfg(feature = "ft817")]
|
||||
RigKind::Ft817,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RigKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
#[cfg(feature = "ft817")]
|
||||
RigKind::Ft817 => write!(f, "ft817"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection details for instantiating a rig backend.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RigAccess {
|
||||
Serial { path: String, baud: u32 },
|
||||
Tcp { addr: String },
|
||||
}
|
||||
|
||||
/// Instantiate a rig backend based on the selected kind and access method.
|
||||
pub fn build_rig(kind: RigKind, access: RigAccess) -> DynResult<Box<dyn RigCat>> {
|
||||
match (kind, access) {
|
||||
// Yaesu FT-817
|
||||
#[cfg(feature = "ft817")]
|
||||
(RigKind::Ft817, RigAccess::Serial { path, baud }) => {
|
||||
Ok(Box::new(Ft817::new(&path, baud)?))
|
||||
}
|
||||
#[cfg(feature = "ft817")]
|
||||
(RigKind::Ft817, RigAccess::Tcp { .. }) => {
|
||||
Err("FT-817 only supports serial CAT access".into())
|
||||
}
|
||||
|
||||
// Fallback for unsupported combinations
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => Err("Selected rig is not enabled/available".into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-backend-ft817"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-serial = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -0,0 +1,596 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_serial::{ClearBuffer, SerialPort, SerialPortBuilderExt, SerialStream};
|
||||
|
||||
use trx_core::math::{decode_freq_bcd, encode_freq_bcd};
|
||||
use trx_core::radio::freq::{Band, Freq};
|
||||
use trx_core::rig::{
|
||||
Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigStatusFuture, RigVfo, RigVfoEntry,
|
||||
};
|
||||
use trx_core::{DynResult, RigMode};
|
||||
|
||||
/// Backend for Yaesu FT-817 CAT control.
|
||||
pub struct Ft817 {
|
||||
port: SerialStream,
|
||||
info: RigInfo,
|
||||
vfo_side: Ft817VfoSide,
|
||||
vfo_a_freq: Option<Freq>,
|
||||
vfo_b_freq: Option<Freq>,
|
||||
vfo_a_mode: Option<RigMode>,
|
||||
vfo_b_mode: Option<RigMode>,
|
||||
}
|
||||
|
||||
impl Ft817 {
|
||||
const READ_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
|
||||
pub fn new(path: &str, baud: u32) -> DynResult<Self> {
|
||||
let builder = tokio_serial::new(path, baud);
|
||||
let port = builder.open_native_async()?;
|
||||
let info = RigInfo {
|
||||
manufacturer: "Yaesu",
|
||||
model: "FT-817",
|
||||
revision: "",
|
||||
capabilities: RigCapabilities {
|
||||
supported_bands: vec![
|
||||
// Transmit-capable amateur bands
|
||||
Band {
|
||||
low_hz: 1_800_000,
|
||||
high_hz: 2_000_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 3_500_000,
|
||||
high_hz: 4_000_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 5_250_000,
|
||||
high_hz: 5_450_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 7_000_000,
|
||||
high_hz: 7_300_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 10_100_000,
|
||||
high_hz: 10_150_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 14_000_000,
|
||||
high_hz: 14_350_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 18_068_000,
|
||||
high_hz: 18_168_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 21_000_000,
|
||||
high_hz: 21_450_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 24_890_000,
|
||||
high_hz: 24_990_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 28_000_000,
|
||||
high_hz: 29_700_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 50_000_000,
|
||||
high_hz: 54_000_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 144_000_000,
|
||||
high_hz: 148_000_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
Band {
|
||||
low_hz: 430_000_000,
|
||||
high_hz: 450_000_000,
|
||||
tx_allowed: true,
|
||||
},
|
||||
// Receive-only coverage segments
|
||||
Band {
|
||||
low_hz: 100_000,
|
||||
high_hz: 1_799_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 2_000_001,
|
||||
high_hz: 3_499_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 4_000_001,
|
||||
high_hz: 5_249_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 5_450_001,
|
||||
high_hz: 6_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 7_300_001,
|
||||
high_hz: 10_099_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 10_150_001,
|
||||
high_hz: 13_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 14_350_001,
|
||||
high_hz: 18_067_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 18_168_001,
|
||||
high_hz: 20_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 21_450_001,
|
||||
high_hz: 24_889_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 24_990_001,
|
||||
high_hz: 27_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 29_700_001,
|
||||
high_hz: 49_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 54_000_001,
|
||||
high_hz: 75_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 76_000_000,
|
||||
high_hz: 107_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 108_000_000,
|
||||
high_hz: 143_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 148_000_001,
|
||||
high_hz: 429_999_999,
|
||||
tx_allowed: false,
|
||||
},
|
||||
Band {
|
||||
low_hz: 450_000_001,
|
||||
high_hz: 470_000_000,
|
||||
tx_allowed: false,
|
||||
},
|
||||
],
|
||||
supported_modes: vec![
|
||||
RigMode::LSB,
|
||||
RigMode::USB,
|
||||
RigMode::CW,
|
||||
RigMode::CWR,
|
||||
RigMode::AM,
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
num_vfos: 2,
|
||||
// CAT only exposes lock and VFO toggle; the other features are panel-only.
|
||||
lockable: true,
|
||||
attenuator: false,
|
||||
preamp: false,
|
||||
rit: false,
|
||||
rpt: false,
|
||||
split: false,
|
||||
lock: true,
|
||||
},
|
||||
access: RigAccessMethod::Serial {
|
||||
path: path.to_string(),
|
||||
baud,
|
||||
},
|
||||
};
|
||||
Ok(Self {
|
||||
port,
|
||||
info,
|
||||
vfo_side: Ft817VfoSide::Unknown,
|
||||
vfo_a_freq: None,
|
||||
vfo_b_freq: None,
|
||||
vfo_a_mode: None,
|
||||
vfo_b_mode: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Query current status (frequency, mode, VFO) from FT-817.
|
||||
pub async fn get_status(&mut self) -> DynResult<(Freq, RigMode, Option<RigVfo>)> {
|
||||
let (hz, mode) = self.read_status().await?;
|
||||
let freq = Freq { hz };
|
||||
self.update_vfo_freq(freq);
|
||||
self.update_vfo_mode(mode.clone());
|
||||
let mut entries = Vec::new();
|
||||
if let Some(a) = self.vfo_a_freq {
|
||||
entries.push(RigVfoEntry {
|
||||
name: "A".to_string(),
|
||||
freq: a,
|
||||
mode: self.vfo_a_mode.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(b) = self.vfo_b_freq {
|
||||
entries.push(RigVfoEntry {
|
||||
name: "B".to_string(),
|
||||
freq: b,
|
||||
mode: self.vfo_b_mode.clone(),
|
||||
});
|
||||
}
|
||||
let active = match self.vfo_side {
|
||||
Ft817VfoSide::A if self.vfo_a_freq.is_some() => Some(0),
|
||||
Ft817VfoSide::B if self.vfo_a_freq.is_some() => Some(1),
|
||||
Ft817VfoSide::B if self.vfo_a_freq.is_none() && self.vfo_b_freq.is_some() => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
let vfo = if entries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(RigVfo { entries, active })
|
||||
};
|
||||
Ok((freq, mode, vfo))
|
||||
}
|
||||
|
||||
/// Query current frequency from FT-817.
|
||||
pub async fn get_freq(&mut self) -> DynResult<Freq> {
|
||||
let (freq, _, _) = self.get_status().await?;
|
||||
Ok(freq)
|
||||
}
|
||||
|
||||
/// Query current mode from FT-817.
|
||||
pub async fn get_mode(&mut self) -> DynResult<RigMode> {
|
||||
let (_, mode, _) = self.get_status().await?;
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
/// Send CAT command to set frequency on FT-817.
|
||||
pub async fn set_freq(&mut self, freq: Freq) -> DynResult<()> {
|
||||
let bcd = encode_freq_bcd(freq.hz)?;
|
||||
let frame = [bcd[0], bcd[1], bcd[2], bcd[3], CMD_SET_FREQ];
|
||||
self.write_frame(&frame).await?;
|
||||
self.update_vfo_freq(freq);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send CAT command to set mode on FT-817.
|
||||
pub async fn set_mode(&mut self, mode: &RigMode) -> DynResult<()> {
|
||||
// Ensure panel is unlocked and drop any stale bytes before sending.
|
||||
let _ = self.unlock().await;
|
||||
let _ = self.port.clear(ClearBuffer::Input);
|
||||
|
||||
// Data byte 1 = mode, data bytes 2-4 = 0x00, command = 0x07.
|
||||
let mode_code = encode_mode(mode);
|
||||
tracing::debug!("FT-817 set_mode -> code 0x{:02X} ({:?})", mode_code, mode);
|
||||
let frame = [mode_code, 0x00, 0x00, 0x00, CMD_SET_MODE];
|
||||
self.write_frame(&frame).await?;
|
||||
self.port.flush().await?;
|
||||
// Some rigs occasionally miss the first frame; send a second time after a short delay.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
|
||||
self.write_frame(&frame).await?;
|
||||
self.port.flush().await?;
|
||||
self.update_vfo_mode(mode.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send CAT command to control PTT on FT-817.
|
||||
pub async fn set_ptt(&mut self, ptt: bool) -> DynResult<()> {
|
||||
let opcode = if ptt { CMD_PTT_ON } else { CMD_PTT_OFF };
|
||||
// PTT on/off does not take a payload; CAT uses separate opcodes.
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, opcode];
|
||||
self.write_frame(&frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn the radio on via CAT. The first frame is ignored while the CPU wakes,
|
||||
/// so send a dummy payload before issuing the actual command.
|
||||
pub async fn power_on(&mut self) -> DynResult<()> {
|
||||
const POWER_ON_DUMMY: [u8; 5] = [0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
self.port.write_all(&POWER_ON_DUMMY).await?;
|
||||
self.port.flush().await?;
|
||||
// Give the radio a moment to wake up and lock onto CAT framing.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
|
||||
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_POWER_ON];
|
||||
self.write_frame(&frame).await?;
|
||||
self.port.flush().await?;
|
||||
// Drop any boot noise that might remain in the input buffer before we start polling.
|
||||
let _ = self.port.clear(ClearBuffer::Input);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn the radio off via CAT.
|
||||
pub async fn power_off(&mut self) -> DynResult<()> {
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_POWER_OFF];
|
||||
self.write_frame(&frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle between VFO A/B.
|
||||
pub async fn toggle_vfo(&mut self) -> DynResult<()> {
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_TOGGLE_VFO];
|
||||
self.write_frame(&frame).await?;
|
||||
self.vfo_side = self.vfo_side.other();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable front panel lock.
|
||||
pub async fn lock(&mut self) -> DynResult<()> {
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_LOCK];
|
||||
self.write_frame(&frame).await?;
|
||||
let mut buf = [0u8; 1];
|
||||
if let Err(e) = self.port.read_exact(&mut buf).await {
|
||||
tracing::warn!("LOCK read failed: {:?}", e);
|
||||
} else {
|
||||
tracing::debug!("LOCK response: 0x{:02X}", buf[0]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disable front panel lock.
|
||||
pub async fn unlock(&mut self) -> DynResult<()> {
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_UNLOCK];
|
||||
self.write_frame(&frame).await?;
|
||||
let mut buf = [0u8; 1];
|
||||
if let Err(e) = self.port.read_exact(&mut buf).await {
|
||||
tracing::warn!("UNLOCK read failed: {:?}", e);
|
||||
} else {
|
||||
tracing::debug!("UNLOCK response: 0x{:02X}", buf[0]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current signal strength meter (S-meter/PWR) from the radio.
|
||||
///
|
||||
/// The returned value is the raw CAT meter byte (0-255). In receive it
|
||||
/// represents S-meter level; in transmit it reports power/ALC depending on
|
||||
/// rig state.
|
||||
pub async fn get_signal_strength(&mut self) -> DynResult<u8> {
|
||||
self.read_meter().await
|
||||
}
|
||||
|
||||
/// Read the current transmit power indication (raw meter value).
|
||||
///
|
||||
/// The FT-817 reports the same meter byte for TX power as for the S-meter;
|
||||
/// callers should interpret based on current PTT state.
|
||||
pub async fn get_tx_power(&mut self) -> DynResult<u8> {
|
||||
self.read_meter().await
|
||||
}
|
||||
|
||||
async fn read_status(&mut self) -> DynResult<(u64, RigMode)> {
|
||||
// Status request returns frequency (4 BCD bytes, LSB first) and mode code.
|
||||
let _ = self.port.clear(ClearBuffer::Input);
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_READ_STATUS];
|
||||
self.write_frame(&frame).await?;
|
||||
|
||||
let mut buf = [0u8; 5];
|
||||
timeout(Self::READ_TIMEOUT, self.port.read_exact(&mut buf))
|
||||
.await
|
||||
.map_err(|_| "CAT status read timeout")??;
|
||||
|
||||
let freq = decode_freq_bcd([buf[0], buf[1], buf[2], buf[3]])?;
|
||||
let mode = decode_mode(buf[4]);
|
||||
Ok((freq, mode))
|
||||
}
|
||||
|
||||
async fn read_meter(&mut self) -> DynResult<u8> {
|
||||
let frame = [0x00, 0x00, 0x00, 0x00, CMD_READ_METER];
|
||||
self.write_frame(&frame).await?;
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
timeout(Self::READ_TIMEOUT, self.port.read_exact(&mut buf))
|
||||
.await
|
||||
.map_err(|_| "CAT meter read timeout")??;
|
||||
Ok(buf[0])
|
||||
}
|
||||
|
||||
async fn write_frame(&mut self, frame: &[u8; 5]) -> DynResult<()> {
|
||||
self.port.write_all(frame).await?;
|
||||
self.port.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_vfo_freq(&mut self, freq: Freq) {
|
||||
match self.vfo_side {
|
||||
Ft817VfoSide::A => self.vfo_a_freq = Some(freq),
|
||||
Ft817VfoSide::B => self.vfo_b_freq = Some(freq),
|
||||
Ft817VfoSide::Unknown => {
|
||||
// Try to infer which VFO we are on using cached values; default to A only.
|
||||
if self.vfo_b_freq.map(|f| f.hz == freq.hz).unwrap_or(false)
|
||||
&& self.vfo_a_freq.is_none()
|
||||
{
|
||||
self.vfo_side = Ft817VfoSide::B;
|
||||
self.vfo_b_freq = Some(freq);
|
||||
} else {
|
||||
self.vfo_side = Ft817VfoSide::A;
|
||||
self.vfo_a_freq = Some(freq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_vfo_mode(&mut self, mode: RigMode) {
|
||||
match self.vfo_side {
|
||||
Ft817VfoSide::A => self.vfo_a_mode = Some(mode),
|
||||
Ft817VfoSide::B => self.vfo_b_mode = Some(mode),
|
||||
Ft817VfoSide::Unknown => {
|
||||
// Default to current VFO (assume A) when unknown.
|
||||
self.vfo_a_mode = Some(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Rig for Ft817 {
|
||||
fn info(&self) -> &RigInfo {
|
||||
&self.info
|
||||
}
|
||||
}
|
||||
|
||||
impl RigCat for Ft817 {
|
||||
fn get_status<'a>(&'a mut self) -> RigStatusFuture<'a> {
|
||||
Box::pin(async move { self.get_status().await })
|
||||
}
|
||||
|
||||
fn set_freq<'a>(
|
||||
&'a mut self,
|
||||
freq: Freq,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::set_freq(self, freq).await })
|
||||
}
|
||||
|
||||
fn set_mode<'a>(
|
||||
&'a mut self,
|
||||
mode: RigMode,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::set_mode(self, &mode).await })
|
||||
}
|
||||
|
||||
fn set_ptt<'a>(
|
||||
&'a mut self,
|
||||
ptt: bool,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::set_ptt(self, ptt).await })
|
||||
}
|
||||
|
||||
fn power_on<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::power_on(self).await })
|
||||
}
|
||||
|
||||
fn power_off<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::power_off(self).await })
|
||||
}
|
||||
|
||||
fn get_signal_strength<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::get_signal_strength(self).await })
|
||||
}
|
||||
|
||||
fn get_tx_power<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::get_tx_power(self).await })
|
||||
}
|
||||
|
||||
fn get_tx_limit<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||
Box::pin(async move { Err("TX limit query not supported on FT-817".into()) })
|
||||
}
|
||||
|
||||
fn set_tx_limit<'a>(
|
||||
&'a mut self,
|
||||
_limit: u8,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Err("TX limit setting not supported on FT-817".into()) })
|
||||
}
|
||||
|
||||
fn toggle_vfo<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::toggle_vfo(self).await })
|
||||
}
|
||||
|
||||
fn lock<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::lock(self).await })
|
||||
}
|
||||
|
||||
fn unlock<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move { Ft817::unlock(self).await })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Ft817VfoSide {
|
||||
A,
|
||||
B,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Ft817VfoSide {
|
||||
fn other(self) -> Self {
|
||||
match self {
|
||||
Ft817VfoSide::A => Ft817VfoSide::B,
|
||||
Ft817VfoSide::B => Ft817VfoSide::A,
|
||||
Ft817VfoSide::Unknown => Ft817VfoSide::A,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Command codes per Yaesu CAT protocol.
|
||||
const CMD_SET_FREQ: u8 = 0x01;
|
||||
const CMD_READ_STATUS: u8 = 0x03;
|
||||
const CMD_SET_MODE: u8 = 0x07;
|
||||
const CMD_PTT_ON: u8 = 0x08;
|
||||
const CMD_PTT_OFF: u8 = 0x88;
|
||||
const CMD_POWER_ON: u8 = 0x0F;
|
||||
const CMD_POWER_OFF: u8 = 0x8F;
|
||||
const CMD_TOGGLE_VFO: u8 = 0x81;
|
||||
const CMD_LOCK: u8 = 0x00;
|
||||
const CMD_UNLOCK: u8 = 0x80;
|
||||
const CMD_READ_METER: u8 = 0xE7;
|
||||
|
||||
fn encode_mode(mode: &RigMode) -> u8 {
|
||||
match mode {
|
||||
RigMode::LSB => 0x00,
|
||||
RigMode::USB => 0x01,
|
||||
RigMode::CW => 0x02,
|
||||
RigMode::CWR => 0x03,
|
||||
RigMode::AM => 0x04,
|
||||
RigMode::WFM => 0x06,
|
||||
RigMode::FM => 0x08,
|
||||
RigMode::DIG => 0x0A,
|
||||
RigMode::PKT => 0x0C,
|
||||
RigMode::Other(_) => 0x00,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_mode(code: u8) -> RigMode {
|
||||
match code {
|
||||
0x00 => RigMode::LSB,
|
||||
0x01 => RigMode::USB,
|
||||
0x02 => RigMode::CW,
|
||||
0x03 => RigMode::CWR,
|
||||
0x04 => RigMode::AM,
|
||||
0x06 => RigMode::WFM,
|
||||
0x08 => RigMode::FM,
|
||||
0x0A => RigMode::DIG,
|
||||
0x0C => RigMode::PKT,
|
||||
other => RigMode::Other(format!("0x{:02X}", other)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-bin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-serial = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
trx-backend = { path = "../trx-backend" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-frontend = { path = "../trx-frontend" }
|
||||
trx-frontend-http = { path = "../trx-frontend/src/trx-frontend-http" }
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
/// Detect the specific CAT decode error for invalid BCD digits.
|
||||
pub fn is_invalid_bcd_error(err: &(dyn Error + 'static)) -> bool {
|
||||
if err.to_string().contains("invalid BCD digit in frequency") {
|
||||
return true;
|
||||
}
|
||||
err.source().map(is_invalid_bcd_error).unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::time::{self, Duration, Instant};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
mod error;
|
||||
|
||||
use crate::error::is_invalid_bcd_error;
|
||||
use trx_backend::{build_rig, RigAccess, RigKind};
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::command::RigCommand;
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
|
||||
use trx_core::rig::{RigCat, RigControl, RigRxStatus, RigStatus, RigTxStatus};
|
||||
use trx_core::{ClientCommand, ClientResponse, DynResult, RigError, RigResult};
|
||||
use trx_frontend::FrontendSpawner;
|
||||
use trx_frontend_http::server::HttpFrontend;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum FrontendKind {
|
||||
Http,
|
||||
}
|
||||
|
||||
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - ", env!("CARGO_PKG_DESCRIPTION"));
|
||||
const PKG_LONG_ABOUT: &str = concat!(
|
||||
env!("CARGO_PKG_DESCRIPTION"),
|
||||
"\nHomepage: ",
|
||||
env!("CARGO_PKG_HOMEPAGE")
|
||||
);
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author = env!("CARGO_PKG_AUTHORS"),
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
about = PKG_DESCRIPTION,
|
||||
long_about = PKG_LONG_ABOUT
|
||||
)]
|
||||
struct Cli {
|
||||
/// Rig backend to use (e.g. ft817)
|
||||
#[arg(short = 'r', long = "rig", value_enum)]
|
||||
rig: RigKind,
|
||||
/// Access method to reach the rig CAT interface
|
||||
#[arg(short = 'a', long = "access", value_enum, default_value_t = AccessKind::Serial)]
|
||||
access: AccessKind,
|
||||
/// Frontend to expose for control/status (e.g. http)
|
||||
#[arg(short = 'f', long = "frontend", value_enum, default_value_t = FrontendKind::Http)]
|
||||
frontend: FrontendKind,
|
||||
/// Rig CAT address:
|
||||
/// when access is serial: <path> <baud>;
|
||||
/// when access is TCP: <host>:<port>
|
||||
#[arg(value_name = "RIG_ADDR")]
|
||||
rig_addr: String,
|
||||
/// Optional callsign/owner label to show in the frontend
|
||||
#[arg(short = 'c', long = "callsign")]
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum AccessKind {
|
||||
Serial,
|
||||
Tcp,
|
||||
}
|
||||
|
||||
/// Parse a serial rig address of the form "<path> <baud>".
|
||||
fn parse_serial_addr(addr: &str) -> DynResult<(String, u32)> {
|
||||
let mut parts = addr.split_whitespace();
|
||||
let path = parts
|
||||
.next()
|
||||
.ok_or("Serial rig address must be '<path> <baud>'")?;
|
||||
let baud_str = parts
|
||||
.next()
|
||||
.ok_or("Serial rig address must be '<path> <baud>'")?;
|
||||
if parts.next().is_some() {
|
||||
return Err("Serial rig address must be '<path> <baud>' (got extra data)".into());
|
||||
}
|
||||
let baud: u32 = baud_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid baud '{}': {}", baud_str, e))?;
|
||||
Ok((path.to_string(), baud))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> DynResult<()> {
|
||||
init_tracing();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let access = match cli.access {
|
||||
AccessKind::Serial => {
|
||||
let (path, baud) = parse_serial_addr(&cli.rig_addr)?;
|
||||
info!(
|
||||
"Starting trxd (rig: {}, access: serial {} @ {} baud)",
|
||||
cli.rig, path, baud
|
||||
);
|
||||
RigAccess::Serial { path, baud }
|
||||
}
|
||||
AccessKind::Tcp => {
|
||||
info!(
|
||||
"Starting trxd (rig: {}, access: tcp {})",
|
||||
cli.rig, cli.rig_addr
|
||||
);
|
||||
RigAccess::Tcp {
|
||||
addr: cli.rig_addr.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Channel used to communicate with the rig task.
|
||||
let (tx, rx) = mpsc::channel::<RigRequest>(32);
|
||||
let initial_state = RigState {
|
||||
rig_info: None,
|
||||
status: RigStatus {
|
||||
freq: Freq { hz: 144_300_000 },
|
||||
mode: RigMode::USB,
|
||||
tx_en: false,
|
||||
vfo: None,
|
||||
tx: Some(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
}),
|
||||
rx: Some(RigRxStatus { sig: None }),
|
||||
lock: Some(false),
|
||||
},
|
||||
initialized: false,
|
||||
control: RigControl {
|
||||
rpt_offset_hz: None,
|
||||
ctcss_hz: None,
|
||||
dcs_code: None,
|
||||
lock: Some(false),
|
||||
clar_hz: None,
|
||||
clar_on: None,
|
||||
enabled: Some(false),
|
||||
},
|
||||
};
|
||||
let (state_tx, state_rx) = watch::channel(initial_state.clone());
|
||||
|
||||
// Spawn the rig task.
|
||||
let _rig_handle = tokio::spawn(rig_task(cli.rig, access, rx, state_tx, initial_state));
|
||||
|
||||
// Start TCP listener for clients.
|
||||
let listen_addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
let actual_addr = listener.local_addr()?;
|
||||
info!("TCP listener started on {}", actual_addr);
|
||||
|
||||
// Start simple HTTP status server on 127.0.0.1:8080.
|
||||
let http_state_rx = state_rx.clone();
|
||||
if matches!(cli.frontend, FrontendKind::Http) {
|
||||
HttpFrontend::spawn_frontend(http_state_rx, tx.clone(), cli.callsign.clone());
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = listener.accept() => {
|
||||
let (socket, addr) = res?;
|
||||
info!("New client connected: {}", addr);
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(socket, addr, tx_clone).await {
|
||||
error!("Client {} error: {:?}", addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = signal::ctrl_c() => {
|
||||
info!("Ctrl+C received, shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize logging/tracing.
|
||||
fn init_tracing() {
|
||||
// Uses default formatting and RUST_LOG if available.
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
}
|
||||
|
||||
/// Task that owns the TRX state and talks to the serial port.
|
||||
async fn rig_task(
|
||||
rig_kind: RigKind,
|
||||
access: RigAccess,
|
||||
mut rx: mpsc::Receiver<RigRequest>,
|
||||
state_tx: watch::Sender<RigState>,
|
||||
mut state: RigState,
|
||||
) -> DynResult<()> {
|
||||
info!("Opening rig backend {}", rig_kind);
|
||||
match &access {
|
||||
RigAccess::Serial { path, baud } => info!("Serial: {} @ {} baud", path, baud),
|
||||
RigAccess::Tcp { addr } => info!("TCP CAT: {}", addr),
|
||||
}
|
||||
|
||||
let mut rig: Box<dyn RigCat> = build_rig(rig_kind, access)?;
|
||||
info!("Rig backend ready");
|
||||
|
||||
let mut poll = time::interval(Duration::from_millis(250));
|
||||
let mut poll_pause_until: Option<Instant> = None;
|
||||
let mut last_power_on: Option<Instant> = None;
|
||||
|
||||
// Initial bring-up and VFO priming.
|
||||
let rig_info = rig.info().clone();
|
||||
state.rig_info = Some(rig_info);
|
||||
if let Some(info) = state.rig_info.as_ref() {
|
||||
info!(
|
||||
"Rig info: {} {} {}",
|
||||
info.manufacturer, info.model, info.revision
|
||||
);
|
||||
}
|
||||
let _ = state_tx.send(state.clone());
|
||||
if !state.control.enabled.unwrap_or(false) {
|
||||
info!("Sending initial PowerOn to wake rig");
|
||||
match rig.power_on().await {
|
||||
Ok(()) => {
|
||||
state.control.enabled = Some(true);
|
||||
time::sleep(Duration::from_secs(3)).await;
|
||||
if let Err(e) = refresh_state_with_retry(&mut rig, &mut state, 2).await {
|
||||
warn!(
|
||||
"Initial PowerOn refresh failed: {:?}; retrying once after short delay",
|
||||
e
|
||||
);
|
||||
time::sleep(Duration::from_millis(500)).await;
|
||||
if let Err(e2) = refresh_state_with_retry(&mut rig, &mut state, 1).await {
|
||||
warn!(
|
||||
"Initial PowerOn second refresh failed (continuing): {:?}",
|
||||
e2
|
||||
);
|
||||
}
|
||||
}
|
||||
info!("Rig initialized after power on sequence");
|
||||
}
|
||||
Err(e) => warn!("Initial PowerOn failed (continuing): {:?}", e),
|
||||
}
|
||||
}
|
||||
if let Err(e) = prime_vfo_state(&mut rig, &mut state).await {
|
||||
warn!("VFO priming failed: {:?}", e);
|
||||
}
|
||||
state.initialized = true;
|
||||
let _ = state_tx.send(state.clone());
|
||||
|
||||
// Single-task loop: handle commands and periodic polling.
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = poll.tick() => {
|
||||
if let Some(until) = poll_pause_until {
|
||||
if Instant::now() < until {
|
||||
continue;
|
||||
} else {
|
||||
poll_pause_until = None;
|
||||
}
|
||||
}
|
||||
if matches!(state.control.enabled, Some(false)) {
|
||||
continue;
|
||||
}
|
||||
match refresh_state_with_retry(&mut rig, &mut state, 2).await {
|
||||
Ok(()) => { let _ = state_tx.send(state.clone()); }
|
||||
Err(e) => {
|
||||
error!("CAT polling error: {:?}", e);
|
||||
if let Some(last_on) = last_power_on {
|
||||
if Instant::now().duration_since(last_on) < Duration::from_secs(5) {
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(800));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
maybe_req = rx.recv() => {
|
||||
let Some(first_req) = maybe_req else { break; };
|
||||
let mut batch = vec![first_req];
|
||||
while let Ok(next) = rx.try_recv() {
|
||||
batch.push(next);
|
||||
}
|
||||
while let Some(RigRequest { cmd, respond_to }) = batch.pop() {
|
||||
let responders = vec![respond_to];
|
||||
let cmd_label = format!("{:?}", cmd);
|
||||
let started = Instant::now();
|
||||
|
||||
let result: RigResult<RigSnapshot> = {
|
||||
let not_ready = !state.initialized
|
||||
&& !matches!(cmd, RigCommand::PowerOn | RigCommand::GetSnapshot);
|
||||
if not_ready {
|
||||
Err(RigError("rig not initialized yet".into()))
|
||||
} else {
|
||||
match cmd {
|
||||
RigCommand::GetSnapshot => match refresh_state_with_retry(&mut rig, &mut state, 2).await {
|
||||
Ok(()) => {
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read CAT status: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
},
|
||||
RigCommand::SetFreq(freq) => {
|
||||
info!("SetFreq requested: {} Hz", freq.hz);
|
||||
if state.control.lock.unwrap_or(false) {
|
||||
warn!("SetFreq blocked: panel lock is active");
|
||||
Err(RigError("panel is locked".into()))
|
||||
} else {
|
||||
let res = time::timeout(Duration::from_secs(1), rig.set_freq(freq)).await;
|
||||
match res {
|
||||
Ok(Ok(())) => {
|
||||
state.apply_freq(freq);
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(200));
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to send CAT SetFreq: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
Err(elapsed) => {
|
||||
warn!("CAT SetFreq timed out ({:?}) but proceeding with state update", elapsed);
|
||||
state.apply_freq(freq);
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(200));
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RigCommand::SetMode(mode) => {
|
||||
info!("SetMode requested: {:?}", mode);
|
||||
if state.control.lock.unwrap_or(false) {
|
||||
warn!("SetMode blocked: panel lock is active");
|
||||
Err(RigError("panel is locked".into()))
|
||||
} else {
|
||||
let res = time::timeout(Duration::from_secs(1), rig.set_mode(mode.clone())).await;
|
||||
match res {
|
||||
Ok(Ok(())) => {
|
||||
state.apply_mode(mode.clone());
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(200));
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to send CAT SetMode: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
Err(elapsed) => {
|
||||
warn!("CAT SetMode timed out ({:?}) but proceeding with state update", elapsed);
|
||||
state.apply_mode(mode.clone());
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(200));
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RigCommand::SetPtt(ptt) => {
|
||||
info!("SetPtt requested: {}", ptt);
|
||||
if let Err(e) = rig.set_ptt(ptt).await {
|
||||
error!("Failed to send CAT SetPtt: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
} else {
|
||||
state.status.tx_en = ptt;
|
||||
if !ptt {
|
||||
if let Some(tx) = state.status.tx.as_mut() {
|
||||
tx.power = Some(0);
|
||||
tx.swr = Some(0.0);
|
||||
}
|
||||
}
|
||||
state.status.lock = state.control.lock;
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
}
|
||||
RigCommand::PowerOn => {
|
||||
info!("PowerOn requested");
|
||||
if let Err(e) = rig.power_on().await {
|
||||
error!("Failed to send CAT PowerOn: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
} else {
|
||||
state.control.enabled = Some(true);
|
||||
time::sleep(Duration::from_secs(3)).await;
|
||||
let now = Instant::now();
|
||||
poll_pause_until = Some(now + Duration::from_secs(3));
|
||||
last_power_on = Some(now);
|
||||
match refresh_state_with_retry(&mut rig, &mut state, 2).await {
|
||||
Ok(()) => {
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
if is_invalid_bcd_error(e.as_ref()) {
|
||||
warn!("Transient CAT decode after PowerOn (ignored): {:?}", e);
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(1500));
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
} else {
|
||||
error!("Failed to refresh after PowerOn: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RigCommand::PowerOff => {
|
||||
info!("PowerOff requested");
|
||||
if let Err(e) = rig.power_off().await {
|
||||
error!("Failed to send CAT PowerOff: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
} else {
|
||||
state.control.enabled = Some(false);
|
||||
state.status.tx_en = false;
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
}
|
||||
RigCommand::ToggleVfo => {
|
||||
info!("Toggle VFO requested");
|
||||
if state.control.lock.unwrap_or(false) {
|
||||
warn!("ToggleVfo blocked: panel lock is active");
|
||||
Err(RigError("panel is locked".into()))
|
||||
} else if let Err(e) = rig.toggle_vfo().await {
|
||||
error!("Failed to send CAT ToggleVfo: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
} else {
|
||||
time::sleep(Duration::from_millis(150)).await;
|
||||
poll_pause_until = Some(Instant::now() + Duration::from_millis(300));
|
||||
match refresh_state_with_retry(&mut rig, &mut state, 2).await {
|
||||
Ok(()) => {
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to refresh after ToggleVfo: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RigCommand::GetTxLimit => match rig.get_tx_limit().await {
|
||||
Ok(limit) => {
|
||||
state
|
||||
.status
|
||||
.tx
|
||||
.get_or_insert(RigTxStatus { power: None, limit: None, swr: None, alc: None })
|
||||
.limit = Some(limit);
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read TX limit: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
},
|
||||
RigCommand::SetTxLimit(limit) => match rig.set_tx_limit(limit).await {
|
||||
Ok(()) => {
|
||||
state
|
||||
.status
|
||||
.tx
|
||||
.get_or_insert(RigTxStatus { power: None, limit: None, swr: None, alc: None })
|
||||
.limit = Some(limit);
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to set TX limit: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
}
|
||||
RigCommand::Lock => {
|
||||
info!("Lock requested");
|
||||
match rig.lock().await {
|
||||
Ok(()) => {
|
||||
state.control.lock = Some(true);
|
||||
state.status.lock = Some(true);
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send CAT Lock: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
RigCommand::Unlock => {
|
||||
info!("Unlock requested");
|
||||
match rig.unlock().await {
|
||||
Ok(()) => {
|
||||
state.control.lock = Some(false);
|
||||
state.status.lock = Some(false);
|
||||
let _ = state_tx.send(state.clone());
|
||||
snapshot_from(&state)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send CAT Unlock: {:?}", e);
|
||||
Err(RigError(format!("CAT error: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for tx in responders {
|
||||
let _ = tx.send(result.clone());
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed > Duration::from_millis(500) {
|
||||
warn!("Rig command {} took {:?}", cmd_label, elapsed);
|
||||
} else {
|
||||
debug!("Rig command {} completed in {:?}", cmd_label, elapsed);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
info!("rig_task shutting down (channel closed)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_state_from_cat(trx: &mut Box<dyn RigCat>, state: &mut RigState) -> DynResult<()> {
|
||||
let (freq, mode, vfo) = trx.get_status().await?;
|
||||
state.control.enabled = Some(true);
|
||||
state.apply_freq(freq);
|
||||
state.apply_mode(mode);
|
||||
state.status.vfo = vfo.clone();
|
||||
|
||||
if state.status.tx_en {
|
||||
state.status.rx.get_or_insert(RigRxStatus { sig: None }).sig = Some(0);
|
||||
} else if let Ok(meter) = trx.get_signal_strength().await {
|
||||
let sig = map_signal_strength(&state.status.mode, meter);
|
||||
state.status.rx.get_or_insert(RigRxStatus { sig: None }).sig = Some(sig);
|
||||
}
|
||||
if let Ok(limit) = trx.get_tx_limit().await {
|
||||
state
|
||||
.status
|
||||
.tx
|
||||
.get_or_insert(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
})
|
||||
.limit = Some(limit);
|
||||
}
|
||||
if state.status.tx_en {
|
||||
if let Ok(power) = trx.get_tx_power().await {
|
||||
state
|
||||
.status
|
||||
.tx
|
||||
.get_or_insert(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
})
|
||||
.power = Some(power);
|
||||
}
|
||||
}
|
||||
state.status.lock = Some(state.control.lock.unwrap_or(false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_state_with_retry(
|
||||
trx: &mut Box<dyn RigCat>,
|
||||
state: &mut RigState,
|
||||
attempts: usize,
|
||||
) -> DynResult<()> {
|
||||
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
|
||||
for i in 0..attempts {
|
||||
match refresh_state_from_cat(trx, state).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let should_retry = is_invalid_bcd_error(e.as_ref());
|
||||
last_err = Some(e);
|
||||
if should_retry && i + 1 < attempts {
|
||||
warn!(
|
||||
"Retrying CAT state read after invalid BCD (attempt {} of {})",
|
||||
i + 1,
|
||||
attempts
|
||||
);
|
||||
time::sleep(Duration::from_millis(300)).await;
|
||||
continue;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| "Unknown CAT error".into()))
|
||||
}
|
||||
|
||||
async fn prime_vfo_state(trx: &mut Box<dyn RigCat>, state: &mut RigState) -> DynResult<()> {
|
||||
// Ensure panel is unlocked so we can CAT-control safely.
|
||||
let _ = trx.unlock().await;
|
||||
time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
refresh_state_with_retry(trx, state, 2).await?;
|
||||
time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
trx.toggle_vfo().await?;
|
||||
time::sleep(Duration::from_millis(150)).await;
|
||||
refresh_state_with_retry(trx, state, 2).await?;
|
||||
|
||||
trx.toggle_vfo().await?;
|
||||
time::sleep(Duration::from_millis(150)).await;
|
||||
refresh_state_with_retry(trx, state, 2).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a single TCP client.
|
||||
async fn handle_client(
|
||||
socket: TcpStream,
|
||||
addr: SocketAddr,
|
||||
tx: mpsc::Sender<RigRequest>,
|
||||
) -> DynResult<()> {
|
||||
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!("Client {} disconnected", addr);
|
||||
break;
|
||||
}
|
||||
|
||||
// Simple protocol: one line = one JSON command.
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd: ClientCommand = match serde_json::from_str(trimmed) {
|
||||
Ok(c) => c,
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Map ClientCommand -> RigCommand.
|
||||
let rig_cmd = match 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::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.0),
|
||||
};
|
||||
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 map_signal_strength(mode: &RigMode, raw: u8) -> i32 {
|
||||
let val = raw as i32;
|
||||
match mode {
|
||||
RigMode::FM | RigMode::WFM => val.saturating_sub(128),
|
||||
_ => val,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse mode string coming from the client into RigMode.
|
||||
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 snapshot_from(state: &RigState) -> RigResult<RigSnapshot> {
|
||||
state
|
||||
.snapshot()
|
||||
.ok_or_else(|| RigError("Rig info unavailable".into()))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::rig::state::RigSnapshot;
|
||||
|
||||
/// Command received from network clients (JSON).
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||
pub enum ClientCommand {
|
||||
GetState,
|
||||
SetFreq { freq_hz: u64 },
|
||||
SetMode { mode: String },
|
||||
SetPtt { ptt: bool },
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
ToggleVfo,
|
||||
GetTxLimit,
|
||||
SetTxLimit { limit: u8 },
|
||||
}
|
||||
|
||||
/// Response sent to network clients over TCP.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClientResponse {
|
||||
pub success: bool,
|
||||
pub state: Option<RigSnapshot>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod client;
|
||||
pub mod math;
|
||||
pub mod radio;
|
||||
pub mod rig;
|
||||
|
||||
pub type DynResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
pub use client::{ClientCommand, ClientResponse};
|
||||
pub use rig::command::RigCommand;
|
||||
pub use rig::request::RigRequest;
|
||||
pub use rig::response::{RigError, RigResult};
|
||||
pub use rig::state::{RigMode, RigSnapshot, RigState};
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use crate::DynResult;
|
||||
|
||||
/// Encode frequency in Hz into 4 BCD bytes (10 Hz resolution) used by Yaesu CAT.
|
||||
pub fn encode_freq_bcd(freq_hz: u64) -> DynResult<[u8; 4]> {
|
||||
if !freq_hz.is_multiple_of(10) {
|
||||
return Err("frequency must be a multiple of 10 Hz for CAT encoding".into());
|
||||
}
|
||||
|
||||
let mut n = freq_hz / 10; // FT-817 uses 10 Hz units.
|
||||
if n > 99_999_999 {
|
||||
return Err("frequency out of range for CAT BCD encoding".into());
|
||||
}
|
||||
|
||||
let mut digits = [0u8; 8];
|
||||
for i in (0..8).rev() {
|
||||
digits[i] = (n % 10) as u8;
|
||||
n /= 10;
|
||||
}
|
||||
|
||||
let mut out = [0u8; 4];
|
||||
for i in 0..4 {
|
||||
out[i] = (digits[i * 2] << 4) | digits[i * 2 + 1];
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode 4 BCD bytes (10 Hz resolution) into frequency in Hz.
|
||||
pub fn decode_freq_bcd(bytes: [u8; 4]) -> DynResult<u64> {
|
||||
let mut value = 0u64;
|
||||
|
||||
for b in bytes {
|
||||
let high = (b >> 4) & 0x0F;
|
||||
let low = b & 0x0F;
|
||||
if high >= 10 || low >= 10 {
|
||||
return Err("invalid BCD digit in frequency".into());
|
||||
}
|
||||
|
||||
value = value * 10 + u64::from(high);
|
||||
value = value * 10 + u64::from(low);
|
||||
}
|
||||
|
||||
Ok(value * 10) // Convert back to Hz from 10 Hz units.
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod bcd;
|
||||
|
||||
pub use bcd::{decode_freq_bcd, encode_freq_bcd};
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const SPEED_OF_LIGHT_M_PER_S: f64 = 299_792_458.0;
|
||||
|
||||
/// Supported band range in Hz.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Band {
|
||||
pub low_hz: u64,
|
||||
pub high_hz: u64,
|
||||
pub tx_allowed: bool,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
/// Midpoint frequency of the band in Hz.
|
||||
#[must_use]
|
||||
pub fn center_hz(&self) -> u64 {
|
||||
u64::midpoint(self.low_hz, self.high_hz)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frequency wrapper (Hz).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Freq {
|
||||
pub hz: u64,
|
||||
}
|
||||
|
||||
impl Freq {
|
||||
#[must_use]
|
||||
pub fn new(hz: u64) -> Self {
|
||||
Self { hz }
|
||||
}
|
||||
|
||||
/// Return the band name for this frequency, if any, using the provided band list.
|
||||
pub fn band_name(&self, bands: &[Band]) -> Option<String> {
|
||||
band_for_freq(bands, self).map(band_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the band that contains the given frequency (inclusive), if any.
|
||||
pub fn band_for_freq<'a>(bands: &'a [Band], freq: &Freq) -> Option<&'a Band> {
|
||||
bands
|
||||
.iter()
|
||||
.find(|b| freq.hz >= b.low_hz && freq.hz <= b.high_hz)
|
||||
}
|
||||
|
||||
/// Convert a frequency in Hz to a human-friendly wavelength string.
|
||||
///
|
||||
/// Values above one meter are rounded to the nearest meter; shorter wavelengths
|
||||
/// are shown in centimeters.
|
||||
pub fn wavelength_label(freq_hz: u64) -> String {
|
||||
if freq_hz == 0 {
|
||||
return "-".to_string();
|
||||
}
|
||||
|
||||
let wavelength_m = SPEED_OF_LIGHT_M_PER_S / (freq_hz as f64);
|
||||
if wavelength_m >= 1.0 {
|
||||
format!("{:.0}m", wavelength_m.round())
|
||||
} else {
|
||||
format!("{:.0}cm", (wavelength_m * 100.0).round())
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a human-friendly band label from a band's wavelength.
|
||||
///
|
||||
/// The label is computed from the wavelength at the band's center frequency.
|
||||
pub fn band_name(band: &Band) -> String {
|
||||
wavelength_label(band.center_hz())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod freq;
|
||||
|
||||
pub use freq::{band_for_freq, band_name, wavelength_label, Band, Freq};
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use crate::radio::freq::Freq;
|
||||
use crate::RigMode;
|
||||
|
||||
/// Internal command handled by the rig task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RigCommand {
|
||||
GetSnapshot,
|
||||
SetFreq(Freq),
|
||||
SetMode(RigMode),
|
||||
SetPtt(bool),
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
ToggleVfo,
|
||||
GetTxLimit,
|
||||
SetTxLimit(u8),
|
||||
Lock,
|
||||
Unlock,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::radio::freq::{Band, Freq};
|
||||
use crate::{DynResult, RigMode};
|
||||
|
||||
/// Alias to reduce type complexity in RigCat.
|
||||
pub type RigStatusFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = DynResult<(Freq, RigMode, Option<RigVfo>)>> + Send + 'a>>;
|
||||
|
||||
pub mod command;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod state;
|
||||
|
||||
/// How this backend communicates with the rig.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum RigAccessMethod {
|
||||
Serial { path: String, baud: u32 },
|
||||
Tcp { addr: String },
|
||||
}
|
||||
|
||||
/// Static info describing a rig backend.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigInfo {
|
||||
pub manufacturer: &'static str,
|
||||
pub model: &'static str,
|
||||
pub revision: &'static str,
|
||||
pub capabilities: RigCapabilities,
|
||||
pub access: RigAccessMethod,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigCapabilities {
|
||||
pub supported_bands: Vec<Band>,
|
||||
pub supported_modes: Vec<RigMode>,
|
||||
pub num_vfos: usize,
|
||||
pub lock: bool,
|
||||
pub lockable: bool,
|
||||
pub attenuator: bool,
|
||||
pub preamp: bool,
|
||||
pub rit: bool,
|
||||
pub rpt: bool,
|
||||
pub split: bool,
|
||||
}
|
||||
|
||||
/// Common interface for rig backends.
|
||||
pub trait Rig {
|
||||
fn info(&self) -> &RigInfo;
|
||||
}
|
||||
|
||||
/// Common CAT control operations any rig backend should implement.
|
||||
pub trait RigCat: Rig + Send {
|
||||
fn get_status<'a>(&'a mut self) -> RigStatusFuture<'a>;
|
||||
|
||||
fn set_freq<'a>(
|
||||
&'a mut self,
|
||||
freq: Freq,
|
||||
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn set_mode<'a>(
|
||||
&'a mut self,
|
||||
mode: RigMode,
|
||||
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn set_ptt<'a>(
|
||||
&'a mut self,
|
||||
ptt: bool,
|
||||
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn power_on<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn power_off<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn get_signal_strength<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn Future<Output = DynResult<u8>> + Send + 'a>>;
|
||||
|
||||
fn get_tx_power<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<u8>> + Send + 'a>>;
|
||||
|
||||
fn get_tx_limit<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<u8>> + Send + 'a>>;
|
||||
|
||||
fn set_tx_limit<'a>(
|
||||
&'a mut self,
|
||||
limit: u8,
|
||||
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn toggle_vfo<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn lock<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
|
||||
fn unlock<'a>(&'a mut self) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Snapshot of a rig's status that every backend can expose.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigStatus {
|
||||
pub freq: Freq,
|
||||
pub mode: RigMode,
|
||||
pub tx_en: bool,
|
||||
pub vfo: Option<RigVfo>,
|
||||
pub tx: Option<RigTxStatus>,
|
||||
pub rx: Option<RigRxStatus>,
|
||||
pub lock: Option<bool>,
|
||||
}
|
||||
|
||||
/// Trait for presenting rig status in a backend-agnostic way.
|
||||
pub trait RigStatusProvider {
|
||||
fn status(&self) -> RigStatus;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigVfo {
|
||||
pub entries: Vec<RigVfoEntry>,
|
||||
/// Index into `entries` for the active VFO, if known.
|
||||
pub active: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigVfoEntry {
|
||||
pub name: String,
|
||||
pub freq: Freq,
|
||||
pub mode: Option<RigMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigTxStatus {
|
||||
pub power: Option<u8>,
|
||||
pub limit: Option<u8>,
|
||||
pub swr: Option<f32>,
|
||||
pub alc: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigRxStatus {
|
||||
pub sig: Option<i32>,
|
||||
}
|
||||
|
||||
/// Configurable control settings that can be pushed to the rig.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigControl {
|
||||
pub enabled: Option<bool>,
|
||||
pub lock: Option<bool>,
|
||||
pub clar_hz: Option<i32>,
|
||||
pub clar_on: Option<bool>,
|
||||
pub rpt_offset_hz: Option<i32>,
|
||||
pub ctcss_hz: Option<f32>,
|
||||
pub dcs_code: Option<u16>,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::{RigCommand, RigResult, RigSnapshot};
|
||||
|
||||
/// Request sent to the rig task.
|
||||
#[derive(Debug)]
|
||||
pub struct RigRequest {
|
||||
pub cmd: RigCommand,
|
||||
pub respond_to: oneshot::Sender<RigResult<RigSnapshot>>,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Error type returned by rig requests.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigError(pub String);
|
||||
|
||||
pub type RigResult<T> = Result<T, RigError>;
|
||||
|
||||
impl From<String> for RigError {
|
||||
fn from(value: String) -> Self {
|
||||
RigError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for RigError {
|
||||
fn from(value: &str) -> Self {
|
||||
RigError(value.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::rig::{RigControl, RigInfo, RigStatus, RigStatusProvider};
|
||||
|
||||
/// Simple transceiver state representation held by the rig task.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigState {
|
||||
#[serde(skip_deserializing)]
|
||||
pub rig_info: Option<RigInfo>,
|
||||
pub status: RigStatus,
|
||||
pub initialized: bool,
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub control: RigControl,
|
||||
}
|
||||
|
||||
/// Mode supported by the rig.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum RigMode {
|
||||
LSB,
|
||||
USB,
|
||||
CW,
|
||||
CWR,
|
||||
AM,
|
||||
WFM,
|
||||
FM,
|
||||
DIG,
|
||||
PKT,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl RigStatusProvider for RigState {
|
||||
fn status(&self) -> RigStatus {
|
||||
self.status.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl RigState {
|
||||
pub fn band_name(&self) -> Option<String> {
|
||||
self.rig_info.as_ref().and_then(|info| {
|
||||
self.status
|
||||
.freq
|
||||
.band_name(&info.capabilities.supported_bands)
|
||||
})
|
||||
}
|
||||
|
||||
/// Produce an immutable snapshot suitable for sharing with clients.
|
||||
pub fn snapshot(&self) -> Option<RigSnapshot> {
|
||||
let info = self.rig_info.clone()?;
|
||||
Some(RigSnapshot {
|
||||
info,
|
||||
status: self.status.clone(),
|
||||
band: self.band_name(),
|
||||
enabled: self.control.enabled,
|
||||
initialized: self.initialized,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a frequency change into the state.
|
||||
pub fn apply_freq(&mut self, freq: crate::radio::freq::Freq) {
|
||||
self.status.freq = freq;
|
||||
}
|
||||
|
||||
/// Apply a mode change into the state.
|
||||
pub fn apply_mode(&mut self, mode: RigMode) {
|
||||
self.status.mode = mode;
|
||||
}
|
||||
|
||||
/// Apply a PTT change, resetting meters on TX off.
|
||||
pub fn apply_ptt(&mut self, ptt: bool) {
|
||||
self.status.tx_en = ptt;
|
||||
self.status.lock = self.control.lock;
|
||||
if !ptt {
|
||||
if let Some(tx) = self.status.tx.as_mut() {
|
||||
tx.power = Some(0);
|
||||
tx.swr = Some(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only projection of state shared with clients.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RigSnapshot {
|
||||
pub info: RigInfo,
|
||||
pub status: RigStatus,
|
||||
pub band: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub initialized: bool,
|
||||
}
|
||||
@@ -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,17 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use trx_core::{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>,
|
||||
) -> JoinHandle<()>;
|
||||
}
|
||||
@@ -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,301 @@
|
||||
// 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.0),
|
||||
})),
|
||||
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",
|
||||
model: "Rig",
|
||||
revision: "",
|
||||
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,5 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
pub mod server;
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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 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>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(state_rx, rig_tx, callsign).await {
|
||||
error!("HTTP status server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
) -> 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);
|
||||
server.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_server(
|
||||
addr: (&str, u16),
|
||||
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>
|
||||
"##;
|
||||
Reference in New Issue
Block a user