initial commit
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
/target/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
[submodule "docs"]
|
||||||
|
path = docs
|
||||||
|
url = http://github.com/sgrams/trx-rs.wiki.git
|
||||||
Generated
+2101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: BSD-2-Clause
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"src/trx-bin",
|
||||||
|
"src/trx-backend",
|
||||||
|
"src/trx-backend/src/trx-backend-ft817",
|
||||||
|
"src/trx-core",
|
||||||
|
"src/trx-frontend",
|
||||||
|
"src/trx-frontend/src/trx-frontend-http",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = "1"
|
||||||
|
tokio-serial = "5"
|
||||||
|
serde = "1"
|
||||||
|
serde_json = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
clap = "4"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Copyright (c) 2025 Stan Grams <stanislawgrams@gmail.com>
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="assets/trx-logo.png" alt="trx-rs logo" width="25%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# trx-rs (work in progress)
|
||||||
|
|
||||||
|
This is an early, untested snapshot of a transceiver control stack (core + backend + HTTP frontend). Things may change quickly and APIs are not stable yet. Expect rough edges and bugs; use at your own risk and please report issues you hit. Features, tests and docs are still being written (or not).
|
||||||
|
|
||||||
|
## Supported backends
|
||||||
|
|
||||||
|
- Yaesu FT-817 (feature-gated crate `trx-backend-ft817`)
|
||||||
|
- Planned: other rigs I own; contributions and reports are welcome.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the BSD-2-Clause license. See `LICENSES/` for bundled third-party license files.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Submodule
+1
Submodule docs added at c98dc5ab75
@@ -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