diff --git a/Cargo.lock b/Cargo.lock index b24e86b..c5ec900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2347,10 +2347,22 @@ dependencies = [ "tokio", "tokio-serial", "tracing", + "trx-backend-ft450d", "trx-backend-ft817", "trx-core", ] +[[package]] +name = "trx-backend-ft450d" +version = "0.1.0" +dependencies = [ + "serde", + "tokio", + "tokio-serial", + "tracing", + "trx-core", +] + [[package]] name = "trx-backend-ft817" version = "0.1.0" @@ -2453,6 +2465,14 @@ dependencies = [ "trx-frontend", ] +[[package]] +name = "trx-ft8" +version = "0.1.0" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "trx-server" version = "0.1.0" @@ -2472,6 +2492,7 @@ dependencies = [ "tracing-subscriber", "trx-backend", "trx-core", + "trx-ft8", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fc16cff..e0b0a81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "src/trx-server", "src/trx-server/trx-backend", "src/trx-server/trx-backend/trx-backend-ft817", + "src/trx-server/trx-backend/trx-backend-ft450d", "src/trx-client", "src/trx-client/trx-frontend", "src/trx-client/trx-frontend/trx-frontend-http", diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index baa1f1b..81918f3 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -62,7 +62,7 @@ impl Default for GeneralConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct RigConfig { - /// Rig model (e.g., "ft817", "ic7300") + /// Rig model (e.g., "ft817", "ft450d", "ic7300") pub model: Option, /// Initial frequency (Hz) for the rig state before first CAT read pub initial_freq_hz: u64, diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index e351cd6..3d56634 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -50,7 +50,7 @@ struct Cli { /// Print example configuration and exit #[arg(long = "print-config")] print_config: bool, - /// Rig backend to use (e.g. ft817) + /// Rig backend to use (e.g. ft817, ft450d) #[arg(short = 'r', long = "rig")] rig: Option, /// Access method to reach the rig CAT interface diff --git a/src/trx-server/trx-backend/Cargo.toml b/src/trx-server/trx-backend/Cargo.toml index aea2cd6..dd4fd7f 100644 --- a/src/trx-server/trx-backend/Cargo.toml +++ b/src/trx-server/trx-backend/Cargo.toml @@ -8,12 +8,14 @@ version = "0.1.0" edition = "2021" [features] -default = ["ft817"] +default = ["ft817", "ft450d"] ft817 = ["dep:trx-backend-ft817"] +ft450d = ["dep:trx-backend-ft450d"] [dependencies] trx-core = { path = "../../trx-core" } trx-backend-ft817 = { path = "trx-backend-ft817", optional = true } +trx-backend-ft450d = { path = "trx-backend-ft450d", optional = true } tokio = { workspace = true, features = ["full"] } tokio-serial = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/src/trx-server/trx-backend/src/lib.rs b/src/trx-server/trx-backend/src/lib.rs index f1efceb..1f71db8 100644 --- a/src/trx-server/trx-backend/src/lib.rs +++ b/src/trx-server/trx-backend/src/lib.rs @@ -12,6 +12,8 @@ mod dummy; #[cfg(feature = "ft817")] use trx_backend_ft817::Ft817; +#[cfg(feature = "ft450d")] +use trx_backend_ft450d::Ft450d; /// Connection details for instantiating a rig backend. #[derive(Debug, Clone)] @@ -58,6 +60,8 @@ pub fn register_builtin_backends() { register_backend("dummy", dummy_factory); #[cfg(feature = "ft817")] register_backend("ft817", ft817_factory); + #[cfg(feature = "ft450d")] + register_backend("ft450d", ft450d_factory); } fn dummy_factory(_access: RigAccess) -> DynResult> { @@ -97,3 +101,11 @@ fn ft817_factory(access: RigAccess) -> DynResult> { RigAccess::Tcp { .. } => Err("FT-817 only supports serial CAT access".into()), } } + +#[cfg(feature = "ft450d")] +fn ft450d_factory(access: RigAccess) -> DynResult> { + match access { + RigAccess::Serial { path, baud } => Ok(Box::new(Ft450d::new(&path, baud)?)), + RigAccess::Tcp { .. } => Err("FT-450D only supports serial CAT access".into()), + } +} diff --git a/src/trx-server/trx-backend/trx-backend-ft450d/Cargo.toml b/src/trx-server/trx-backend/trx-backend-ft450d/Cargo.toml new file mode 100644 index 0000000..7157c07 --- /dev/null +++ b/src/trx-server/trx-backend/trx-backend-ft450d/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-backend-ft450d" +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"] } diff --git a/src/trx-server/trx-backend/trx-backend-ft450d/src/lib.rs b/src/trx-server/trx-backend/trx-backend-ft450d/src/lib.rs new file mode 100644 index 0000000..bc0145d --- /dev/null +++ b/src/trx-server/trx-backend/trx-backend-ft450d/src/lib.rs @@ -0,0 +1,528 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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::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-450D CAT control. +pub struct Ft450d { + port: SerialStream, + info: RigInfo, + vfo_side: Ft450dVfoSide, + vfo_a_freq: Option, + vfo_b_freq: Option, + vfo_a_mode: Option, + vfo_b_mode: Option, +} + +impl Ft450d { + const READ_TIMEOUT: Duration = Duration::from_millis(800); + + pub fn new(path: &str, baud: u32) -> DynResult { + let builder = tokio_serial::new(path, baud); + let port = builder.open_native_async()?; + let info = RigInfo { + manufacturer: "Yaesu".to_string(), + model: "FT-450D".to_string(), + revision: "".to_string(), + capabilities: RigCapabilities { + supported_bands: vec![ + // Transmit-capable amateur bands (HF + 6m) + 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, + }, + // Receive-only coverage segments (general coverage) + Band { + low_hz: 30_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: 56_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: Ft450dVfoSide::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-450D. + pub async fn get_status(&mut self) -> DynResult<(Freq, RigMode, Option)> { + 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 { + Ft450dVfoSide::A if self.vfo_a_freq.is_some() => Some(0), + Ft450dVfoSide::B if self.vfo_a_freq.is_some() => Some(1), + Ft450dVfoSide::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-450D. + pub async fn get_freq(&mut self) -> DynResult { + let (freq, _, _) = self.get_status().await?; + Ok(freq) + } + + /// Query current mode from FT-450D. + pub async fn get_mode(&mut self) -> DynResult { + let (_, mode, _) = self.get_status().await?; + Ok(mode) + } + + /// Send CAT command to set frequency on FT-450D. + pub async fn set_freq(&mut self, freq: Freq) -> DynResult<()> { + self.write_cmd(&format!("FA{:08};", freq.hz)).await?; + self.update_vfo_freq(freq); + Ok(()) + } + + /// Send CAT command to set mode on FT-450D. + pub async fn set_mode(&mut self, mode: &RigMode) -> DynResult<()> { + let mode_code = encode_mode(mode)?; + self.write_cmd(&format!("MD0{};", mode_code)).await?; + self.update_vfo_mode(mode.clone()); + Ok(()) + } + + /// Send CAT command to control PTT on FT-450D. + pub async fn set_ptt(&mut self, ptt: bool) -> DynResult<()> { + let cmd = if ptt { "TX1;" } else { "TX0;" }; + self.write_cmd(cmd).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<()> { + self.write_cmd("PS1;").await?; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + let _ = self.port.clear(ClearBuffer::Input); + Ok(()) + } + + /// Turn the radio off via CAT. + pub async fn power_off(&mut self) -> DynResult<()> { + self.write_cmd("PS0;").await?; + Ok(()) + } + + /// Toggle between VFO A/B. + pub async fn toggle_vfo(&mut self) -> DynResult<()> { + Err("VFO toggle not supported on FT-450D backend".into()) + } + + /// Enable front panel lock. + pub async fn lock(&mut self) -> DynResult<()> { + self.write_cmd("LK1;").await?; + Ok(()) + } + + /// Disable front panel lock. + pub async fn unlock(&mut self) -> DynResult<()> { + self.write_cmd("LK0;").await?; + 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 { + self.read_meter("SM0;").await + } + + /// Read the current transmit power indication (raw meter value). + /// + /// The FT-450D 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 { + self.read_meter("RM5;").await + } + + async fn read_status(&mut self) -> DynResult<(u64, RigMode)> { + let freq = self.read_freq().await?; + let mode = self.read_mode().await?; + Ok((freq, mode)) + } + + async fn read_meter(&mut self, cmd: &str) -> DynResult { + let resp = self.query(cmd).await?; + let digits: String = resp.chars().filter(|c| c.is_ascii_digit()).collect(); + let value: u16 = digits.parse().map_err(|_| "CAT meter parse failed")?; + Ok(value.min(255) as u8) + } + + async fn read_freq(&mut self) -> DynResult { + let resp = self.query("FA;").await?; + let data = resp.strip_prefix("FA").ok_or("CAT freq response missing FA")?; + let digits: String = data.chars().filter(|c| c.is_ascii_digit()).collect(); + let freq: u64 = digits.parse().map_err(|_| "CAT freq parse failed")?; + Ok(freq) + } + + async fn read_mode(&mut self) -> DynResult { + let resp = self.query("MD0;").await?; + let data = resp.strip_prefix("MD").ok_or("CAT mode response missing MD")?; + let code = data.chars().last().ok_or("CAT mode parse failed")?; + Ok(decode_mode(code)) + } + + async fn write_cmd(&mut self, cmd: &str) -> DynResult<()> { + self.port.write_all(cmd.as_bytes()).await?; + self.port.flush().await?; + Ok(()) + } + + async fn read_response(&mut self) -> DynResult { + let mut buf = Vec::new(); + let read = async { + loop { + let mut byte = [0u8; 1]; + self.port.read_exact(&mut byte).await?; + if byte[0] == b';' { + break; + } + buf.push(byte[0]); + } + Ok::<(), std::io::Error>(()) + }; + timeout(Self::READ_TIMEOUT, read) + .await + .map_err(|_| "CAT read timeout")??; + Ok(String::from_utf8_lossy(&buf).to_string()) + } + + async fn query(&mut self, cmd: &str) -> DynResult { + let _ = self.port.clear(ClearBuffer::Input); + self.write_cmd(cmd).await?; + self.read_response().await + } + + fn update_vfo_freq(&mut self, freq: Freq) { + match self.vfo_side { + Ft450dVfoSide::A => self.vfo_a_freq = Some(freq), + Ft450dVfoSide::B => self.vfo_b_freq = Some(freq), + Ft450dVfoSide::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 = Ft450dVfoSide::B; + self.vfo_b_freq = Some(freq); + } else { + self.vfo_side = Ft450dVfoSide::A; + self.vfo_a_freq = Some(freq); + } + } + } + } + + fn update_vfo_mode(&mut self, mode: RigMode) { + match self.vfo_side { + Ft450dVfoSide::A => self.vfo_a_mode = Some(mode), + Ft450dVfoSide::B => self.vfo_b_mode = Some(mode), + Ft450dVfoSide::Unknown => { + // Default to current VFO (assume A) when unknown. + self.vfo_a_mode = Some(mode); + } + } + } +} + +impl Rig for Ft450d { + fn info(&self) -> &RigInfo { + &self.info + } +} + +impl RigCat for Ft450d { + 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> + Send + 'a>> { + Box::pin(async move { Ft450d::set_freq(self, freq).await }) + } + + fn set_mode<'a>( + &'a mut self, + mode: RigMode, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::set_mode(self, &mode).await }) + } + + fn set_ptt<'a>( + &'a mut self, + ptt: bool, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::set_ptt(self, ptt).await }) + } + + fn power_on<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::power_on(self).await }) + } + + fn power_off<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::power_off(self).await }) + } + + fn get_signal_strength<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::get_signal_strength(self).await }) + } + + fn get_tx_power<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::get_tx_power(self).await }) + } + + fn get_tx_limit<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Err("TX limit query not supported on FT-450D".into()) }) + } + + fn set_tx_limit<'a>( + &'a mut self, + _limit: u8, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Err("TX limit setting not supported on FT-450D".into()) }) + } + + fn toggle_vfo<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::toggle_vfo(self).await }) + } + + fn lock<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::lock(self).await }) + } + + fn unlock<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { Ft450d::unlock(self).await }) + } +} + +#[derive(Clone, Copy)] +enum Ft450dVfoSide { + A, + B, + Unknown, +} + +fn encode_mode(mode: &RigMode) -> DynResult { + match mode { + RigMode::LSB => Ok('1'), + RigMode::USB => Ok('2'), + RigMode::CW => Ok('3'), + RigMode::FM => Ok('4'), + RigMode::AM => Ok('5'), + RigMode::DIG => Ok('6'), + RigMode::CWR => Ok('7'), + RigMode::PKT => Ok('9'), + RigMode::WFM => Ok('4'), + RigMode::Other(_) => Err("Unsupported mode for FT-450D".into()), + } +} + +fn decode_mode(code: char) -> RigMode { + match code { + '1' => RigMode::LSB, + '2' => RigMode::USB, + '3' => RigMode::CW, + '4' => RigMode::FM, + '5' => RigMode::AM, + '6' => RigMode::DIG, + '7' => RigMode::CWR, + '8' => RigMode::DIG, + '9' => RigMode::PKT, + 'B' | 'b' => RigMode::FM, + 'C' | 'c' => RigMode::DIG, + other => RigMode::Other(format!("mode {}", other)), + } +}