Compare commits

...

8 Commits

Author SHA1 Message Date
sjg cc64c51fd0 [feat](trx-frontend-http): make map source filter select exclusively on click
Clicking a source chip now isolates that source instead of toggling it.
Clicking the already-isolated source restores default visibility.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-18 07:18:23 +01:00
sjg 723da3f7ed [fix](trx-frontend-http): make decode history lists fill remaining viewport
Replace the fixed max-height: 360px on FT8/FT4/FT2/WSPR message
containers with flex-based layout so they grow to fill the available
space. Make #content, .tab-panel, and .sub-tab-panel flex containers
that propagate height down the layout chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 23:20:49 +01:00
sjg 7cf829ef52 [feat](trx-rs): display APRS-IS connection status on About page
Thread aprs_is_status through RigState, RigSnapshot, and the protocol
layer following the same pattern as pskreporter_status. Show the
connection target and callsign when enabled, or "Disabled" otherwise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 23:20:43 +01:00
sjg 2517ed0b29 [fix](trx-frontend): fix spectrum screenshot hotkey
Preserve the WebGL drawing buffers used by the spectrum snapshot,
flush them before compositing, and move the shortcut listener to
capture phase so focused widgets do not swallow it.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 23:11:10 +01:00
sjg 9019acee0e [feat](trx-backend-soapysdr): enable hardware AGC by default if available
Query the device for AGC support via has_gain_mode and enable it
automatically at startup. Devices without hardware AGC fall back to
manual gain as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:57:20 +01:00
sjg cf4c262456 [fix](trx-wspr): reduce false positives with stricter validation
Restrict accepted power levels to the 19 valid WSPR values instead of
any 0-60. Require a digit at position 1 or 2 of the trimmed callsign
per the WSPR encoding rules. Skip candidates whose sync correlation
score falls below a minimum threshold before attempting Fano decode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:57:13 +01:00
sjg 7527770c0c [fix](trx-frontend): let decoder disable take scheduler control
When a scheduler-managed decoder is manually disabled from the frontend,
take scheduler control first so the manual change overrides the current
scheduler cycle like a direct frequency change does.

Track decoder enabled state on the toggle buttons and only take over
when the click is actually disabling FT8, FT4, FT2, WSPR, or HF APRS.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:39:00 +01:00
sjg b533d704a1 [style](trx-rs): reformat codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:36:11 +01:00
39 changed files with 579 additions and 300 deletions
+10 -6
View File
@@ -25,6 +25,11 @@ const DT_SEARCH_STEP_SAMPLES: isize = (WSPR_SAMPLE_RATE as isize) / 2;
// Number of top frequency candidates to try full decode on // Number of top frequency candidates to try full decode on
const MAX_FREQ_CANDIDATES: usize = 8; const MAX_FREQ_CANDIDATES: usize = 8;
// Minimum sync correlation score to attempt a full decode. Candidates below
// this threshold are almost certainly noise and skipping them avoids expensive
// Fano decode attempts that would produce false positives.
const MIN_SYNC_SCORE: f32 = 10.0;
/// WSPR sync vector (162 bits). symbol = sync[i] + 2*data[i]. /// WSPR sync vector (162 bits). symbol = sync[i] + 2*data[i].
/// The LSB of each received symbol should match this pattern. /// The LSB of each received symbol should match this pattern.
#[rustfmt::skip] #[rustfmt::skip]
@@ -123,7 +128,10 @@ impl WsprDecoder {
let mut results = Vec::new(); let mut results = Vec::new();
let mut seen_messages = std::collections::HashSet::new(); let mut seen_messages = std::collections::HashSet::new();
for &(freq, dt_samples, _) in candidates.iter().take(MAX_FREQ_CANDIDATES) { for &(freq, dt_samples, score) in candidates.iter().take(MAX_FREQ_CANDIDATES) {
if score < MIN_SYNC_SCORE {
break; // candidates are sorted by score, no point continuing
}
let start = (EXPECTED_SIGNAL_START_SAMPLES as isize + dt_samples) as usize; let start = (EXPECTED_SIGNAL_START_SAMPLES as isize + dt_samples) as usize;
let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES]; let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES];
@@ -166,11 +174,7 @@ fn sync_correlation_score(signal: &[f32], base_hz: f32) -> f32 {
base_hz + 2.0 * TONE_SPACING_HZ, base_hz + 2.0 * TONE_SPACING_HZ,
WSPR_SAMPLE_RATE as f32, WSPR_SAMPLE_RATE as f32,
); );
let p1 = goertzel_power( let p1 = goertzel_power(frame, base_hz + TONE_SPACING_HZ, WSPR_SAMPLE_RATE as f32);
frame,
base_hz + TONE_SPACING_HZ,
WSPR_SAMPLE_RATE as f32,
);
let p3 = goertzel_power( let p3 = goertzel_power(
frame, frame,
base_hz + 3.0 * TONE_SPACING_HZ, base_hz + 3.0 * TONE_SPACING_HZ,
+19 -2
View File
@@ -141,9 +141,12 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
power_code = (power_code << 1) | b as u32; power_code = (power_code << 1) | b as u32;
} }
// power_code is the raw dBm value; valid WSPR levels are 060 dBm. // WSPR only permits specific power levels (dBm).
const VALID_POWER: [i32; 19] = [
0, 3, 7, 10, 13, 17, 20, 23, 27, 30, 33, 37, 40, 43, 47, 50, 53, 57, 60,
];
let power_dbm = power_code as i32; let power_dbm = power_code as i32;
if !(0..=60).contains(&power_dbm) { if !VALID_POWER.contains(&power_dbm) {
return None; return None;
} }
@@ -182,9 +185,23 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
.trim() .trim()
.to_string(); .to_string();
// WSPR callsigns: after trimming, the digit (from position 2 of the
// 6-char padded form) must appear at index 1 or 2. The callsign must
// also contain at least one letter and be at least 3 characters long.
if callsign.len() < 3 || !callsign.chars().any(|c| c.is_alphabetic()) { if callsign.len() < 3 || !callsign.chars().any(|c| c.is_alphabetic()) {
return None; return None;
} }
let has_digit_at_1_or_2 = callsign
.chars()
.nth(1)
.is_some_and(|c| c.is_ascii_digit())
|| callsign
.chars()
.nth(2)
.is_some_and(|c| c.is_ascii_digit());
if !has_digit_at_1_or_2 {
return None;
}
// Decode Maidenhead grid from M1. // Decode Maidenhead grid from M1.
// M1 = (179 - 10*loc1 - loc3)*180 + 10*loc2 + loc4 // M1 = (179 - 10*loc1 - loc3)*180 + 10*loc2 + loc4
+9 -7
View File
@@ -25,11 +25,11 @@ use trx_core::audio::{
parse_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, parse_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg,
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME,
AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE, AUDIO_MSG_WSPR_DECODE,
}; };
use trx_core::decode::DecodedMessage; use trx_core::decode::DecodedMessage;
use trx_frontend::VChanAudioCmd; use trx_frontend::VChanAudioCmd;
@@ -195,7 +195,8 @@ async fn handle_audio_connection(
} }
// Re-apply non-default bandwidth after re-subscribing. // Re-apply non-default bandwidth after re-subscribing.
if sub.bandwidth_hz > 0 { if sub.bandwidth_hz > 0 {
let bw_json = serde_json::json!({ "uuid": uuid.to_string(), "bandwidth_hz": sub.bandwidth_hz }); let bw_json =
serde_json::json!({ "uuid": uuid.to_string(), "bandwidth_hz": sub.bandwidth_hz });
if let Ok(payload) = serde_json::to_vec(&bw_json) { if let Ok(payload) = serde_json::to_vec(&bw_json) {
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_BW, &payload).await { if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_BW, &payload).await {
warn!("Audio vchan reconnect BW write failed: {}", e); warn!("Audio vchan reconnect BW write failed: {}", e);
@@ -209,7 +210,8 @@ async fn handle_audio_connection(
// Spawn RX read task // Spawn RX read task
let rx_tx = rx_tx.clone(); let rx_tx = rx_tx.clone();
let decode_tx = decode_tx.clone(); let decode_tx = decode_tx.clone();
let vchan_audio_rx: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>> = Arc::clone(vchan_audio); let vchan_audio_rx: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>> =
Arc::clone(vchan_audio);
let vchan_destroyed_for_rx = vchan_destroyed_tx.clone(); let vchan_destroyed_for_rx = vchan_destroyed_tx.clone();
let mut rx_handle = tokio::spawn(async move { let mut rx_handle = tokio::spawn(async move {
loop { loop {
+6 -10
View File
@@ -395,9 +395,7 @@ impl ClientConfig {
); );
} }
if self.frontends.http.decode_history_retention_min == 0 { if self.frontends.http.decode_history_retention_min == 0 {
return Err( return Err("[frontends.http].decode_history_retention_min must be > 0".to_string());
"[frontends.http].decode_history_retention_min must be > 0".to_string(),
);
} }
for (rig_id, minutes) in &self.frontends.http.decode_history_retention_min_by_rig { for (rig_id, minutes) in &self.frontends.http.decode_history_retention_min_by_rig {
if rig_id.trim().is_empty() { if rig_id.trim().is_empty() {
@@ -616,13 +614,11 @@ mod tests {
assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000); assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000);
assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92); assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92);
assert_eq!(config.frontends.http.decode_history_retention_min, 1440); assert_eq!(config.frontends.http.decode_history_retention_min, 1440);
assert!( assert!(config
config .frontends
.frontends .http
.http .decode_history_retention_min_by_rig
.decode_history_retention_min_by_rig .is_empty());
.is_empty()
);
assert_eq!(config.frontends.rigctl.port, 4532); assert_eq!(config.frontends.rigctl.port, 4532);
assert!(config.frontends.http_json.enabled); assert!(config.frontends.http_json.enabled);
assert_eq!(config.frontends.http_json.port, 0); assert_eq!(config.frontends.http_json.port, 0);
+61 -60
View File
@@ -185,8 +185,11 @@ async fn async_init() -> DynResult<AppState> {
cfg.frontends.http.spectrum_usable_span_ratio; cfg.frontends.http.spectrum_usable_span_ratio;
frontend_runtime.http_decode_history_retention_min = frontend_runtime.http_decode_history_retention_min =
cfg.frontends.http.decode_history_retention_min; cfg.frontends.http.decode_history_retention_min;
frontend_runtime.http_decode_history_retention_min_by_rig = frontend_runtime.http_decode_history_retention_min_by_rig = cfg
cfg.frontends.http.decode_history_retention_min_by_rig.clone(); .frontends
.http
.decode_history_retention_min_by_rig
.clone();
// Resolve remote URL: CLI > config [remote] section > error // Resolve remote URL: CLI > config [remote] section > error
let remote_url = cli let remote_url = cli
@@ -305,8 +308,7 @@ async fn async_init() -> DynResult<AppState> {
frontend_runtime.decode_rx = Some(decode_tx.clone()); frontend_runtime.decode_rx = Some(decode_tx.clone());
// Virtual-channel audio: shared broadcaster map + command channel. // Virtual-channel audio: shared broadcaster map + command channel.
let (vchan_cmd_tx, vchan_cmd_rx) = let (vchan_cmd_tx, vchan_cmd_rx) = mpsc::unbounded_channel::<trx_frontend::VChanAudioCmd>();
mpsc::unbounded_channel::<trx_frontend::VChanAudioCmd>();
*frontend_runtime.vchan_audio_cmd.lock().unwrap() = Some(vchan_cmd_tx); *frontend_runtime.vchan_audio_cmd.lock().unwrap() = Some(vchan_cmd_tx);
let (vchan_destroyed_tx, _) = broadcast::channel::<uuid::Uuid>(64); let (vchan_destroyed_tx, _) = broadcast::channel::<uuid::Uuid>(64);
@@ -318,65 +320,64 @@ async fn async_init() -> DynResult<AppState> {
let cw_history = frontend_runtime.cw_history.clone(); let cw_history = frontend_runtime.cw_history.clone();
let ft8_history = frontend_runtime.ft8_history.clone(); let ft8_history = frontend_runtime.ft8_history.clone();
let wspr_history = frontend_runtime.wspr_history.clone(); let wspr_history = frontend_runtime.wspr_history.clone();
let replay_history_sink: Arc<dyn Fn(DecodedMessage) + Send + Sync> = let replay_history_sink: Arc<dyn Fn(DecodedMessage) + Send + Sync> = Arc::new(move |msg| {
Arc::new(move |msg| { let now = std::time::Instant::now();
let now = std::time::Instant::now(); match msg {
match msg { DecodedMessage::Ais(mut message) => {
DecodedMessage::Ais(mut message) => { if message.ts_ms.is_none() {
if message.ts_ms.is_none() { message.ts_ms = Some(current_timestamp_ms());
message.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = ais_history.lock() {
history.push_back((now, message));
}
} }
DecodedMessage::Vdes(mut message) => { if let Ok(mut history) = ais_history.lock() {
if message.ts_ms.is_none() { history.push_back((now, message));
message.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = vdes_history.lock() {
history.push_back((now, message));
}
}
DecodedMessage::Aprs(mut packet) => {
if packet.ts_ms.is_none() {
packet.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = aprs_history.lock() {
history.push_back((now, packet));
}
}
DecodedMessage::HfAprs(mut packet) => {
if packet.ts_ms.is_none() {
packet.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = hf_aprs_history.lock() {
history.push_back((now, packet));
}
}
DecodedMessage::Cw(event) => {
if let Ok(mut history) = cw_history.lock() {
history.push_back((now, event));
}
}
DecodedMessage::Ft8(message) => {
if let Ok(mut history) = ft8_history.lock() {
history.push_back((now, message));
}
}
DecodedMessage::Ft4(_) => {
// FT4 history is managed by the frontend HTTP audio collector
}
DecodedMessage::Ft2(_) => {
// FT2 history is managed by the frontend HTTP audio collector
}
DecodedMessage::Wspr(message) => {
if let Ok(mut history) = wspr_history.lock() {
history.push_back((now, message));
}
} }
} }
}); DecodedMessage::Vdes(mut message) => {
if message.ts_ms.is_none() {
message.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = vdes_history.lock() {
history.push_back((now, message));
}
}
DecodedMessage::Aprs(mut packet) => {
if packet.ts_ms.is_none() {
packet.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = aprs_history.lock() {
history.push_back((now, packet));
}
}
DecodedMessage::HfAprs(mut packet) => {
if packet.ts_ms.is_none() {
packet.ts_ms = Some(current_timestamp_ms());
}
if let Ok(mut history) = hf_aprs_history.lock() {
history.push_back((now, packet));
}
}
DecodedMessage::Cw(event) => {
if let Ok(mut history) = cw_history.lock() {
history.push_back((now, event));
}
}
DecodedMessage::Ft8(message) => {
if let Ok(mut history) = ft8_history.lock() {
history.push_back((now, message));
}
}
DecodedMessage::Ft4(_) => {
// FT4 history is managed by the frontend HTTP audio collector
}
DecodedMessage::Ft2(_) => {
// FT2 history is managed by the frontend HTTP audio collector
}
DecodedMessage::Wspr(message) => {
if let Ok(mut history) = wspr_history.lock() {
history.push_back((now, message));
}
}
}
});
info!( info!(
"Audio enabled: default port {}, decode channel set", "Audio enabled: default port {}, decode channel set",
+17 -29
View File
@@ -68,10 +68,7 @@ pub async fn run_remote_client(
) -> RigResult<()> { ) -> RigResult<()> {
// Spectrum polling runs on its own dedicated TCP connection so it never // Spectrum polling runs on its own dedicated TCP connection so it never
// blocks state polls or user commands on the main connection. // blocks state polls or user commands on the main connection.
let spectrum_task = tokio::spawn(run_spectrum_connection( let spectrum_task = tokio::spawn(run_spectrum_connection(config.clone(), shutdown_rx.clone()));
config.clone(),
shutdown_rx.clone(),
));
let mut reconnect_delay = Duration::from_secs(1); let mut reconnect_delay = Duration::from_secs(1);
@@ -147,8 +144,7 @@ async fn run_spectrum_connection(
if let Err(e) = stream.set_nodelay(true) { if let Err(e) = stream.set_nodelay(true) {
warn!("Spectrum TCP_NODELAY failed: {}", e); warn!("Spectrum TCP_NODELAY failed: {}", e);
} }
if let Err(e) = if let Err(e) = handle_spectrum_connection(&config, stream, &mut shutdown_rx).await
handle_spectrum_connection(&config, stream, &mut shutdown_rx).await
{ {
warn!("Spectrum connection dropped: {}", e); warn!("Spectrum connection dropped: {}", e);
} }
@@ -301,13 +297,10 @@ async fn send_command(
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?; .map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n'); payload.push('\n');
time::timeout( time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
IO_TIMEOUT, .await
writer.write_all(payload.as_bytes()), .map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
) .map_err(|e| RigError::communication(format!("write failed: {e}")))?;
.await
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
time::timeout(IO_TIMEOUT, writer.flush()) time::timeout(IO_TIMEOUT, writer.flush())
.await .await
.map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))? .map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))?
@@ -347,15 +340,12 @@ async fn send_command_no_state_update(
let mut payload = serde_json::to_string(&envelope) let mut payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?; .map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n'); payload.push('\n');
time::timeout( time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
SPECTRUM_IO_TIMEOUT, .await
writer.write_all(payload.as_bytes()), .map_err(|_| {
) RigError::communication(format!("write timed out after {:?}", SPECTRUM_IO_TIMEOUT))
.await })?
.map_err(|_| { .map_err(|e| RigError::communication(format!("write failed: {e}")))?;
RigError::communication(format!("write timed out after {:?}", SPECTRUM_IO_TIMEOUT))
})?
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush()) time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
.await .await
.map_err(|_| { .map_err(|_| {
@@ -443,13 +433,10 @@ async fn send_get_rigs(
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?; .map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n'); payload.push('\n');
time::timeout( time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
IO_TIMEOUT, .await
writer.write_all(payload.as_bytes()), .map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
) .map_err(|e| RigError::communication(format!("write failed: {e}")))?;
.await
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
time::timeout(IO_TIMEOUT, writer.flush()) time::timeout(IO_TIMEOUT, writer.flush())
.await .await
.map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))? .map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))?
@@ -778,6 +765,7 @@ mod tests {
server_latitude: None, server_latitude: None,
server_longitude: None, server_longitude: None,
pskreporter_status: Some("Disabled".to_string()), pskreporter_status: Some("Disabled".to_string()),
aprs_is_status: Some("Disabled".to_string()),
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: false, cw_decode_enabled: false,
+6 -2
View File
@@ -3,9 +3,9 @@
// SPDX-License-Identifier: BSD-2-Clause // SPDX-License-Identifier: BSD-2-Clause
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::RwLock;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicUsize}; use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::RwLock;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
@@ -95,7 +95,11 @@ pub struct SharedSpectrum {
impl SharedSpectrum { impl SharedSpectrum {
/// Replace the stored frame, pre-serialising RDS in one pass. /// Replace the stored frame, pre-serialising RDS in one pass.
pub fn set(&mut self, frame: Option<SpectrumData>, vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>) { pub fn set(
&mut self,
frame: Option<SpectrumData>,
vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>,
) {
self.rds_json = frame self.rds_json = frame
.as_ref() .as_ref()
.and_then(|f| f.rds.as_ref()) .and_then(|f| f.rds.as_ref())
@@ -403,6 +403,7 @@ mod tests {
server_latitude: None, server_latitude: None,
server_longitude: None, server_longitude: None,
pskreporter_status: Some("Disabled".to_string()), pskreporter_status: Some("Disabled".to_string()),
aprs_is_status: Some("Disabled".to_string()),
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: false, cw_decode_enabled: false,
@@ -339,11 +339,13 @@ const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
const overviewCanvas = document.getElementById("overview-canvas"); const overviewCanvas = document.getElementById("overview-canvas");
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
// Screenshots composite these live WebGL canvases into a PNG.
const spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true };
const overviewGl = typeof createTrxWebGlRenderer === "function" const overviewGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(overviewCanvas, { alpha: true }) ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions)
: null; : null;
const signalOverlayGl = typeof createTrxWebGlRenderer === "function" const signalOverlayGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(signalOverlayCanvas, { alpha: true }) ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions)
: null; : null;
const signalVisualBlockEl = document.querySelector(".signal-visual-block"); const signalVisualBlockEl = document.querySelector(".signal-visual-block");
const signalSplitControlEl = document.getElementById("signal-split-control"); const signalSplitControlEl = document.getElementById("signal-split-control");
@@ -2875,6 +2877,7 @@ function render(update) {
const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn"); const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn");
if (ft8ToggleBtn) { if (ft8ToggleBtn) {
const ft8On = !!update.ft8_decode_enabled; const ft8On = !!update.ft8_decode_enabled;
ft8ToggleBtn.dataset.enabled = ft8On ? "true" : "false";
ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8"; ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8";
ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : ""; ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : "";
ft8ToggleBtn.style.color = ft8On ? "#00d17f" : ""; ft8ToggleBtn.style.color = ft8On ? "#00d17f" : "";
@@ -2882,6 +2885,7 @@ function render(update) {
const ft4ToggleBtn = document.getElementById("ft4-decode-toggle-btn"); const ft4ToggleBtn = document.getElementById("ft4-decode-toggle-btn");
if (ft4ToggleBtn) { if (ft4ToggleBtn) {
const ft4On = !!update.ft4_decode_enabled; const ft4On = !!update.ft4_decode_enabled;
ft4ToggleBtn.dataset.enabled = ft4On ? "true" : "false";
ft4ToggleBtn.textContent = ft4On ? "Disable FT4" : "Enable FT4"; ft4ToggleBtn.textContent = ft4On ? "Disable FT4" : "Enable FT4";
ft4ToggleBtn.style.borderColor = ft4On ? "#00d17f" : ""; ft4ToggleBtn.style.borderColor = ft4On ? "#00d17f" : "";
ft4ToggleBtn.style.color = ft4On ? "#00d17f" : ""; ft4ToggleBtn.style.color = ft4On ? "#00d17f" : "";
@@ -2889,6 +2893,7 @@ function render(update) {
const ft2ToggleBtn = document.getElementById("ft2-decode-toggle-btn"); const ft2ToggleBtn = document.getElementById("ft2-decode-toggle-btn");
if (ft2ToggleBtn) { if (ft2ToggleBtn) {
const ft2On = !!update.ft2_decode_enabled; const ft2On = !!update.ft2_decode_enabled;
ft2ToggleBtn.dataset.enabled = ft2On ? "true" : "false";
ft2ToggleBtn.textContent = ft2On ? "Disable FT2" : "Enable FT2"; ft2ToggleBtn.textContent = ft2On ? "Disable FT2" : "Enable FT2";
ft2ToggleBtn.style.borderColor = ft2On ? "#00d17f" : ""; ft2ToggleBtn.style.borderColor = ft2On ? "#00d17f" : "";
ft2ToggleBtn.style.color = ft2On ? "#00d17f" : ""; ft2ToggleBtn.style.color = ft2On ? "#00d17f" : "";
@@ -2896,6 +2901,7 @@ function render(update) {
const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn"); const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn");
if (wsprToggleBtn) { if (wsprToggleBtn) {
const wsprOn = !!update.wspr_decode_enabled; const wsprOn = !!update.wspr_decode_enabled;
wsprToggleBtn.dataset.enabled = wsprOn ? "true" : "false";
wsprToggleBtn.textContent = wsprOn ? "Disable WSPR" : "Enable WSPR"; wsprToggleBtn.textContent = wsprOn ? "Disable WSPR" : "Enable WSPR";
wsprToggleBtn.style.borderColor = wsprOn ? "#00d17f" : ""; wsprToggleBtn.style.borderColor = wsprOn ? "#00d17f" : "";
wsprToggleBtn.style.color = wsprOn ? "#00d17f" : ""; wsprToggleBtn.style.color = wsprOn ? "#00d17f" : "";
@@ -2903,6 +2909,7 @@ function render(update) {
const hfAprsToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); const hfAprsToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
if (hfAprsToggleBtn) { if (hfAprsToggleBtn) {
const hfAprsOn = !!update.hf_aprs_decode_enabled; const hfAprsOn = !!update.hf_aprs_decode_enabled;
hfAprsToggleBtn.dataset.enabled = hfAprsOn ? "true" : "false";
hfAprsToggleBtn.textContent = hfAprsOn ? "Disable HF APRS" : "Enable HF APRS"; hfAprsToggleBtn.textContent = hfAprsOn ? "Disable HF APRS" : "Enable HF APRS";
hfAprsToggleBtn.style.borderColor = hfAprsOn ? "#00d17f" : ""; hfAprsToggleBtn.style.borderColor = hfAprsOn ? "#00d17f" : "";
hfAprsToggleBtn.style.color = hfAprsOn ? "#00d17f" : ""; hfAprsToggleBtn.style.color = hfAprsOn ? "#00d17f" : "";
@@ -3014,6 +3021,9 @@ function render(update) {
if (update.pskreporter_status) { if (update.pskreporter_status) {
document.getElementById("about-pskreporter").textContent = update.pskreporter_status; document.getElementById("about-pskreporter").textContent = update.pskreporter_status;
} }
if (update.aprs_is_status) {
document.getElementById("about-aprs-is").textContent = update.aprs_is_status;
}
if (update.info) { if (update.info) {
const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" "); const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" ");
if (parts) document.getElementById("about-rig-info").textContent = parts; if (parts) document.getElementById("about-rig-info").textContent = parts;
@@ -3247,6 +3257,16 @@ async function postPath(path) {
return resp; return resp;
} }
async function takeSchedulerControlForDecoderDisable(buttonEl) {
const enabled = buttonEl?.dataset?.enabled === "true"
|| /^\s*Disable\b/i.test(buttonEl?.textContent || "");
if (!enabled) return;
if (typeof window.vchanTakeSchedulerControl === "function") {
await window.vchanTakeSchedulerControl();
}
}
window.takeSchedulerControlForDecoderDisable = takeSchedulerControlForDecoderDisable;
async function switchRigFromSelect(selectEl) { async function switchRigFromSelect(selectEl) {
if (!selectEl || !selectEl.value) { if (!selectEl || !selectEl.value) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
@@ -4873,7 +4893,7 @@ function renderMapLocatorChipRow(container, items, selectedSet, kind) {
}); });
if (kind === "source") { if (kind === "source") {
if (isDefaultSourceState) { if (isDefaultSourceState) {
helperText = "Default: all non-bookmark sources visible"; helperText = "Click a source to isolate it";
} }
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
@@ -5499,7 +5519,15 @@ function initAprsMap() {
const key = String(chip.dataset.filterKey || ""); const key = String(chip.dataset.filterKey || "");
if (!key) return; if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
mapFilter[key] = !mapFilter[key]; const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
const onlyThisSelected = mapFilter[key] && sourceKeys.every((k) => mapFilter[k] === (k === key));
if (onlyThisSelected) {
// clicking the sole active source restores defaults
for (const k of sourceKeys) mapFilter[k] = DEFAULT_MAP_SOURCE_FILTER[k];
} else {
// select only the clicked source
for (const k of sourceKeys) mapFilter[k] = (k === key);
}
if (!mapFilter.aprs && selectedAprsTrackCall) { if (!mapFilter.aprs && selectedAprsTrackCall) {
const entry = stationMarkers.get(String(selectedAprsTrackCall)); const entry = stationMarkers.get(String(selectedAprsTrackCall));
if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) {
@@ -7905,7 +7933,7 @@ window.addEventListener("beforeunload", () => {
// ── Spectrum display ───────────────────────────────────────────────────────── // ── Spectrum display ─────────────────────────────────────────────────────────
const spectrumCanvas = document.getElementById("spectrum-canvas"); const spectrumCanvas = document.getElementById("spectrum-canvas");
const spectrumGl = typeof createTrxWebGlRenderer === "function" const spectrumGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(spectrumCanvas, { alpha: true }) ? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions)
: null; : null;
const spectrumDbAxis = document.getElementById("spectrum-db-axis"); const spectrumDbAxis = document.getElementById("spectrum-db-axis");
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
@@ -9088,6 +9116,16 @@ function buildSpectrumSnapshotCanvas() {
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) { if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
return null; return null;
} }
for (const renderer of [overviewGl, spectrumGl, signalOverlayGl]) {
const gl = renderer?.gl;
if (!gl) continue;
try {
if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish();
} catch (_) {
// Ignore transient WebGL state errors and capture the last good frame.
}
}
const rootRect = rootEl.getBoundingClientRect(); const rootRect = rootEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const out = document.createElement("canvas"); const out = document.createElement("canvas");
@@ -9138,35 +9176,55 @@ function buildSpectrumSnapshotCanvas() {
return out; return out;
} }
function saveCanvasAsPng(canvas, fileName) { function clickCanvasDownload(href, fileName) {
if (!canvas) return;
if (typeof canvas.toBlob === "function") {
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}, "image/png");
return;
}
const a = document.createElement("a"); const a = document.createElement("a");
a.href = canvas.toDataURL("image/png"); a.href = href;
a.download = fileName; a.download = fileName;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click(); a.click();
requestAnimationFrame(() => a.remove());
} }
function captureSpectrumScreenshot() { function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return Promise.resolve(false);
if (typeof canvas.toBlob === "function") {
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => {
if (!blob) {
resolve(false);
return;
}
const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1000);
resolve(true);
}, "image/png");
} catch (_) {
resolve(false);
}
});
}
try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true);
} catch (_) {
return Promise.resolve(false);
}
}
async function captureSpectrumScreenshot() {
const snapshotCanvas = buildSpectrumSnapshotCanvas(); const snapshotCanvas = buildSpectrumSnapshotCanvas();
if (!snapshotCanvas) { if (!snapshotCanvas) {
showHint("Spectrum view not ready", 1300); showHint("Spectrum view not ready", 1300);
return; return false;
} }
const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const stamp = new Date().toISOString().replace(/[:.]/g, "-");
saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`); const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
showHint("Spectrum screenshot saved", 1500); showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved;
} }
function shouldIgnoreGlobalShortcut(target) { function shouldIgnoreGlobalShortcut(target) {
@@ -9178,13 +9236,13 @@ function shouldIgnoreGlobalShortcut(target) {
} }
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (event.defaultPrevented || event.repeat) return; if (event.defaultPrevented || event.repeat || event.isComposing) return;
if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.ctrlKey || event.metaKey || event.altKey) return;
if (shouldIgnoreGlobalShortcut(event.target)) return; if (shouldIgnoreGlobalShortcut(event.target)) return;
if ((event.key || "").toLowerCase() !== "s") return; if ((event.key || "").toLowerCase() !== "s") return;
event.preventDefault(); event.preventDefault();
captureSpectrumScreenshot(); void captureSpectrumScreenshot();
}); }, { capture: true });
// ── Zoom helpers ────────────────────────────────────────────────────────────── // ── Zoom helpers ──────────────────────────────────────────────────────────────
function spectrumZoomAt(cssX, cssW, data, factor) { function spectrumZoomAt(cssX, cssW, data, factor) {
@@ -981,6 +981,7 @@
<tr><td>Rigctl endpoint</td><td id="about-rigctl-endpoint">--</td></tr> <tr><td>Rigctl endpoint</td><td id="about-rigctl-endpoint">--</td></tr>
<tr><td>Rigctl clients</td><td id="about-rigctl-clients">--</td></tr> <tr><td>Rigctl clients</td><td id="about-rigctl-clients">--</td></tr>
<tr><td>PSK Reporter</td><td id="about-pskreporter">--</td></tr> <tr><td>PSK Reporter</td><td id="about-pskreporter">--</td></tr>
<tr><td>APRS-IS</td><td id="about-aprs-is">--</td></tr>
<tr><td>Client version</td><td>{pkg} v{ver}</td></tr> <tr><td>Client version</td><td>{pkg} v{ver}</td></tr>
<tr><td>Connected clients</td><td id="about-clients">--</td></tr> <tr><td>Connected clients</td><td id="about-clients">--</td></tr>
</table> </table>
@@ -179,8 +179,14 @@ if (ft2FilterInput) {
}); });
} }
document.getElementById("ft2-decode-toggle-btn")?.addEventListener("click", async () => { const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
try { await postPath("/toggle_ft2_decode"); } catch (e) { console.error("FT2 toggle failed", e); } ft2DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
await postPath("/toggle_ft2_decode");
} catch (e) {
console.error("FT2 toggle failed", e);
}
}); });
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
@@ -179,8 +179,14 @@ if (ft4FilterInput) {
}); });
} }
document.getElementById("ft4-decode-toggle-btn")?.addEventListener("click", async () => { const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
try { await postPath("/toggle_ft4_decode"); } catch (e) { console.error("FT4 toggle failed", e); } ft4DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
await postPath("/toggle_ft4_decode");
} catch (e) {
console.error("FT4 toggle failed", e);
}
}); });
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
@@ -447,8 +447,14 @@ if (ft8MessagesEl) {
}); });
} }
document.getElementById("ft8-decode-toggle-btn").addEventListener("click", async () => { const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
try { await postPath("/toggle_ft8_decode"); } catch (e) { console.error("FT8 toggle failed", e); } ft8DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
await postPath("/toggle_ft8_decode");
} catch (e) {
console.error("FT8 toggle failed", e);
}
}); });
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
@@ -372,8 +372,14 @@ window.restoreHfAprsHistory = function(packets) {
window.onServerHfAprsBatch(packets); window.onServerHfAprsBatch(packets);
}; };
document.getElementById("hf-aprs-decode-toggle-btn")?.addEventListener("click", async () => { const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
try { await postPath("/toggle_hf_aprs_decode"); } catch (e) { console.error("HF APRS toggle failed", e); } hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
}); });
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
@@ -111,6 +111,7 @@ async function vchanTakeSchedulerControl() {
console.error("scheduler control takeover failed", e); console.error("scheduler control takeover failed", e);
} }
} }
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
// Called by app.js when the SSE `session` event arrives. // Called by app.js when the SSE `session` event arrives.
function vchanHandleSession(data) { function vchanHandleSession(data) {
@@ -255,8 +255,14 @@ if (wsprMessagesEl) {
}); });
} }
document.getElementById("wspr-decode-toggle-btn").addEventListener("click", async () => { const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
try { await postPath("/toggle_wspr_decode"); } catch (e) { console.error("WSPR toggle failed", e); } wsprDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await postPath("/toggle_wspr_decode");
} catch (e) {
console.error("WSPR toggle failed", e);
}
}); });
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
@@ -1088,8 +1088,9 @@ small { color: var(--text-muted); }
.meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; } .meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
.meter-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 150ms ease; } .meter-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 150ms ease; }
.meter-value { font-size: 0.95rem; color: var(--text-heading); min-width: 64px; text-align: right; } .meter-value { font-size: 0.95rem; color: var(--text-heading); min-width: 64px; text-align: right; }
#content { display: flex; flex-direction: column; gap: 1.1rem; min-height: 0; overflow: visible; } #content { display: flex; flex-direction: column; gap: 1.1rem; min-height: 0; flex: 1 1 auto; overflow: visible; }
.tab-panel { flex: 1 1 auto; min-height: 0; overflow: visible; } .tab-panel { flex: 1 1 auto; min-height: 0; overflow: visible; display: flex; flex-direction: column; }
.sub-tab-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.tab-bar { .tab-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2125,7 +2126,7 @@ body.map-fake-fullscreen-active {
#ft8-messages, #ft8-messages,
#ft4-messages, #ft4-messages,
#ft2-messages, #ft2-messages,
#wspr-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.35rem 0.5rem; } #wspr-messages { flex: 1 1 0; min-height: 120px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.35rem 0.5rem; }
.ft8-row { display: flex; gap: 0.6rem; line-height: 1.4; border-bottom: 1px solid var(--border); padding: 0.25rem 0; } .ft8-row { display: flex; gap: 0.6rem; line-height: 1.4; border-bottom: 1px solid var(--border); padding: 0.25rem 0; }
.ft8-row:last-child { border-bottom: none; } .ft8-row:last-child { border-bottom: none; }
.ft8-time { color: var(--text-muted); min-width: 4.6rem; } .ft8-time { color: var(--text-muted); min-width: 4.6rem; }
@@ -47,8 +47,16 @@ fn base64_encode(data: &[u8]) -> String {
let n = (b0 << 16) | (b1 << 8) | b2; let n = (b0 << 16) | (b1 << 8) | b2;
out.push(T[((n >> 18) & 63) as usize]); out.push(T[((n >> 18) & 63) as usize]);
out.push(T[((n >> 12) & 63) as usize]); out.push(T[((n >> 12) & 63) as usize]);
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] } else { b'=' }); out.push(if chunk.len() > 1 {
out.push(if chunk.len() > 2 { T[(n & 63) as usize] } else { b'=' }); T[((n >> 6) & 63) as usize]
} else {
b'='
});
out.push(if chunk.len() > 2 {
T[(n & 63) as usize]
} else {
b'='
});
} }
// SAFETY: output contains only ASCII base64 characters. // SAFETY: output contains only ASCII base64 characters.
unsafe { String::from_utf8_unchecked(out) } unsafe { String::from_utf8_unchecked(out) }
@@ -120,23 +128,53 @@ fn inject_frontend_meta(json: &str, meta: FrontendMeta) -> String {
// Build only the extra key-value pairs as a JSON fragment. // Build only the extra key-value pairs as a JSON fragment.
let mut extra = serde_json::Map::new(); let mut extra = serde_json::Map::new();
extra.insert("clients".into(), serde_json::json!(meta.http_clients)); extra.insert("clients".into(), serde_json::json!(meta.http_clients));
extra.insert("rigctl_clients".into(), serde_json::json!(meta.rigctl_clients)); extra.insert(
if let Some(v) = meta.rigctl_addr { extra.insert("rigctl_addr".into(), serde_json::json!(v)); } "rigctl_clients".into(),
if let Some(v) = meta.active_rig_id { extra.insert("active_rig_id".into(), serde_json::json!(v)); } serde_json::json!(meta.rigctl_clients),
);
if let Some(v) = meta.rigctl_addr {
extra.insert("rigctl_addr".into(), serde_json::json!(v));
}
if let Some(v) = meta.active_rig_id {
extra.insert("active_rig_id".into(), serde_json::json!(v));
}
extra.insert("rig_ids".into(), serde_json::json!(meta.rig_ids)); extra.insert("rig_ids".into(), serde_json::json!(meta.rig_ids));
if let Some(v) = meta.owner_callsign { extra.insert("owner_callsign".into(), serde_json::json!(v)); } if let Some(v) = meta.owner_callsign {
if let Some(v) = meta.owner_website_url { extra.insert("owner_website_url".into(), serde_json::json!(v)); } extra.insert("owner_callsign".into(), serde_json::json!(v));
if let Some(v) = meta.owner_website_name { extra.insert("owner_website_name".into(), serde_json::json!(v)); } }
if let Some(v) = meta.ais_vessel_url_base { extra.insert("ais_vessel_url_base".into(), serde_json::json!(v)); } if let Some(v) = meta.owner_website_url {
extra.insert("show_sdr_gain_control".into(), serde_json::json!(meta.show_sdr_gain_control)); extra.insert("owner_website_url".into(), serde_json::json!(v));
extra.insert("initial_map_zoom".into(), serde_json::json!(meta.initial_map_zoom)); }
extra.insert("spectrum_coverage_margin_hz".into(), serde_json::json!(meta.spectrum_coverage_margin_hz)); if let Some(v) = meta.owner_website_name {
extra.insert("spectrum_usable_span_ratio".into(), serde_json::json!(meta.spectrum_usable_span_ratio)); extra.insert("owner_website_name".into(), serde_json::json!(v));
}
if let Some(v) = meta.ais_vessel_url_base {
extra.insert("ais_vessel_url_base".into(), serde_json::json!(v));
}
extra.insert(
"show_sdr_gain_control".into(),
serde_json::json!(meta.show_sdr_gain_control),
);
extra.insert(
"initial_map_zoom".into(),
serde_json::json!(meta.initial_map_zoom),
);
extra.insert(
"spectrum_coverage_margin_hz".into(),
serde_json::json!(meta.spectrum_coverage_margin_hz),
);
extra.insert(
"spectrum_usable_span_ratio".into(),
serde_json::json!(meta.spectrum_usable_span_ratio),
);
extra.insert( extra.insert(
"decode_history_retention_min".into(), "decode_history_retention_min".into(),
serde_json::json!(meta.decode_history_retention_min), serde_json::json!(meta.decode_history_retention_min),
); );
extra.insert("server_connected".into(), serde_json::json!(meta.server_connected)); extra.insert(
"server_connected".into(),
serde_json::json!(meta.server_connected),
);
// Serialize the extra map, strip its outer braces, and splice in. // Serialize the extra map, strip its outer braces, and splice in.
let extra_json = match serde_json::to_string(&extra) { let extra_json = match serde_json::to_string(&extra) {
@@ -328,9 +366,7 @@ pub async fn events(
let scheduler_control = scheduler_control_updates.clone(); let scheduler_control = scheduler_control_updates.clone();
async move { async move {
state.snapshot().and_then(|v| { state.snapshot().and_then(|v| {
if let Ok(Some(rig_id)) = if let Ok(Some(rig_id)) = context.remote_active_rig_id.lock().map(|g| g.clone()) {
context.remote_active_rig_id.lock().map(|g| g.clone())
{
vchan.update_primary( vchan.update_primary(
&rig_id, &rig_id,
v.status.freq.hz, v.status.freq.hz,
@@ -367,9 +403,8 @@ pub async fn events(
if let Some(colon) = msg.find(':') { if let Some(colon) = msg.find(':') {
let rig_id = &msg[..colon]; let rig_id = &msg[..colon];
let channels_json = &msg[colon + 1..]; let channels_json = &msg[colon + 1..];
let payload = format!( let payload =
"{{\"rig_id\":\"{rig_id}\",\"channels\":{channels_json}}}" format!("{{\"rig_id\":\"{rig_id}\",\"channels\":{channels_json}}}");
);
return Some(( return Some((
Ok::<Bytes, Error>(Bytes::from(format!( Ok::<Bytes, Error>(Bytes::from(format!(
"event: channels\ndata: {payload}\n\n" "event: channels\ndata: {payload}\n\n"
@@ -573,9 +608,7 @@ fn gzip_bytes(payload: &[u8]) -> std::io::Result<Vec<u8>> {
/// not block real-time messages: the client fetches this endpoint in parallel /// not block real-time messages: the client fetches this endpoint in parallel
/// with opening the SSE connection and drains it in the background. /// with opening the SSE connection and drains it in the background.
#[get("/decode/history")] #[get("/decode/history")]
pub async fn decode_history( pub async fn decode_history(context: web::Data<Arc<FrontendRuntimeContext>>) -> impl Responder {
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> impl Responder {
if context.decode_rx.is_none() { if context.decode_rx.is_none() {
return HttpResponse::NotFound().body("decode not enabled"); return HttpResponse::NotFound().body("decode not enabled");
} }
@@ -1414,9 +1447,7 @@ pub async fn delete_channel_route(
let (rig_id, channel_id) = path.into_inner(); let (rig_id, channel_id) = path.into_inner();
match vchan_mgr.delete_channel(&rig_id, channel_id) { match vchan_mgr.delete_channel(&rig_id, channel_id) {
Ok(()) => HttpResponse::Ok().finish(), Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => { Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
HttpResponse::NotFound().finish()
}
Err(crate::server::vchan::VChanClientError::Permanent) => { Err(crate::server::vchan::VChanClientError::Permanent) => {
HttpResponse::BadRequest().body("cannot remove the primary channel") HttpResponse::BadRequest().body("cannot remove the primary channel")
} }
@@ -1476,9 +1507,7 @@ pub async fn set_vchan_freq(
let (rig_id, channel_id) = path.into_inner(); let (rig_id, channel_id) = path.into_inner();
match vchan_mgr.set_channel_freq(&rig_id, channel_id, body.freq_hz) { match vchan_mgr.set_channel_freq(&rig_id, channel_id, body.freq_hz) {
Ok(()) => HttpResponse::Ok().finish(), Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => { Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
HttpResponse::NotFound().finish()
}
Err(e) => HttpResponse::BadRequest().body(e.to_string()), Err(e) => HttpResponse::BadRequest().body(e.to_string()),
} }
} }
@@ -1497,9 +1526,7 @@ pub async fn set_vchan_bw(
let (rig_id, channel_id) = path.into_inner(); let (rig_id, channel_id) = path.into_inner();
match vchan_mgr.set_channel_bandwidth(&rig_id, channel_id, body.bandwidth_hz) { match vchan_mgr.set_channel_bandwidth(&rig_id, channel_id, body.bandwidth_hz) {
Ok(()) => HttpResponse::Ok().finish(), Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => { Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
HttpResponse::NotFound().finish()
}
Err(e) => HttpResponse::BadRequest().body(e.to_string()), Err(e) => HttpResponse::BadRequest().body(e.to_string()),
} }
} }
@@ -1518,9 +1545,7 @@ pub async fn set_vchan_mode(
let (rig_id, channel_id) = path.into_inner(); let (rig_id, channel_id) = path.into_inner();
match vchan_mgr.set_channel_mode(&rig_id, channel_id, &body.mode) { match vchan_mgr.set_channel_mode(&rig_id, channel_id, &body.mode) {
Ok(()) => HttpResponse::Ok().finish(), Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => { Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
HttpResponse::NotFound().finish()
}
Err(e) => HttpResponse::BadRequest().body(e.to_string()), Err(e) => HttpResponse::BadRequest().body(e.to_string()),
} }
} }
@@ -1783,14 +1808,20 @@ async fn ft8_js() -> impl Responder {
#[get("/ft4.js")] #[get("/ft4.js")]
async fn ft4_js() -> impl Responder { async fn ft4_js() -> impl Responder {
HttpResponse::Ok() HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8")) .insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::FT4_JS) .body(status::FT4_JS)
} }
#[get("/ft2.js")] #[get("/ft2.js")]
async fn ft2_js() -> impl Responder { async fn ft2_js() -> impl Responder {
HttpResponse::Ok() HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8")) .insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::FT2_JS) .body(status::FT2_JS)
} }
@@ -1951,7 +1982,14 @@ fn bookmark_decoder_state(
} }
} }
(want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr) (
want_aprs,
want_hf_aprs,
want_ft8,
want_ft4,
want_ft2,
want_wspr,
)
} }
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> { fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
@@ -2018,7 +2056,8 @@ async fn apply_selected_channel(
let Some(bookmark) = bookmark_store.get(bookmark_id) else { let Some(bookmark) = bookmark_store.get(bookmark_id) else {
return Ok(()); return Ok(());
}; };
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr) = bookmark_decoder_state(&bookmark); let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr) =
bookmark_decoder_state(&bookmark);
let desired = [ let desired = [
RigCommand::SetAprsDecodeEnabled(want_aprs), RigCommand::SetAprsDecodeEnabled(want_aprs),
RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs), RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs),
@@ -2065,6 +2104,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
server_latitude: state.server_latitude, server_latitude: state.server_latitude,
server_longitude: state.server_longitude, server_longitude: state.server_longitude,
pskreporter_status: state.pskreporter_status, pskreporter_status: state.pskreporter_status,
aprs_is_status: state.aprs_is_status,
aprs_decode_enabled: state.aprs_decode_enabled, aprs_decode_enabled: state.aprs_decode_enabled,
hf_aprs_decode_enabled: state.hf_aprs_decode_enabled, hf_aprs_decode_enabled: state.hf_aprs_decode_enabled,
cw_decode_enabled: state.cw_decode_enabled, cw_decode_enabled: state.cw_decode_enabled,
@@ -57,7 +57,10 @@ fn decode_history_cutoff(context: &FrontendRuntimeContext) -> Instant {
Instant::now() - decode_history_retention(context) Instant::now() - decode_history_retention(context)
} }
fn prune_aprs_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, AprsPacket)>) { fn prune_aprs_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, AprsPacket)>,
) {
let cutoff = decode_history_cutoff(context); let cutoff = decode_history_cutoff(context);
while let Some((ts, _)) = history.front() { while let Some((ts, _)) = history.front() {
if *ts >= cutoff { if *ts >= cutoff {
@@ -80,7 +83,10 @@ fn prune_hf_aprs_history(
} }
} }
fn prune_ais_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, AisMessage)>) { fn prune_ais_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, AisMessage)>,
) {
let cutoff = decode_history_cutoff(context); let cutoff = decode_history_cutoff(context);
while let Some((ts, _)) = history.front() { while let Some((ts, _)) = history.front() {
if *ts >= cutoff { if *ts >= cutoff {
@@ -137,7 +143,10 @@ fn prune_cw_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(In
} }
} }
fn prune_ft8_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, Ft8Message)>) { fn prune_ft8_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Ft8Message)>,
) {
let cutoff = decode_history_cutoff(context); let cutoff = decode_history_cutoff(context);
while let Some((ts, _)) = history.front() { while let Some((ts, _)) = history.front() {
if *ts >= cutoff { if *ts >= cutoff {
@@ -147,7 +156,10 @@ fn prune_ft8_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(I
} }
} }
fn prune_ft4_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, Ft8Message)>) { fn prune_ft4_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Ft8Message)>,
) {
let cutoff = decode_history_cutoff(context); let cutoff = decode_history_cutoff(context);
while let Some((ts, _)) = history.front() { while let Some((ts, _)) = history.front() {
if *ts >= cutoff { if *ts >= cutoff {
@@ -157,7 +169,10 @@ fn prune_ft4_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(I
} }
} }
fn prune_ft2_history(context: &FrontendRuntimeContext, history: &mut VecDeque<(Instant, Ft8Message)>) { fn prune_ft2_history(
context: &FrontendRuntimeContext,
history: &mut VecDeque<(Instant, Ft8Message)>,
) {
let cutoff = decode_history_cutoff(context); let cutoff = decode_history_cutoff(context);
while let Some((ts, _)) = history.front() { while let Some((ts, _)) = history.front() {
if *ts >= cutoff { if *ts >= cutoff {
@@ -85,12 +85,24 @@ impl BackgroundDecodeStore {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let db = if path.exists() { let db = if path.exists() {
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) PickleDb::load(
.unwrap_or_else(|_| { path,
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) PickleDbDumpPolicy::AutoDump,
}) SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else { } else {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
}; };
Self { Self {
db: Arc::new(RwLock::new(db)), db: Arc::new(RwLock::new(db)),
@@ -160,11 +172,13 @@ impl BackgroundDecodeManager {
} }
pub fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig { pub fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig {
self.store.get(rig_id).unwrap_or_else(|| BackgroundDecodeConfig { self.store
rig_id: rig_id.to_string(), .get(rig_id)
enabled: false, .unwrap_or_else(|| BackgroundDecodeConfig {
bookmark_ids: Vec::new(), rig_id: rig_id.to_string(),
}) enabled: false,
bookmark_ids: Vec::new(),
})
} }
pub fn put_config(&self, mut config: BackgroundDecodeConfig) -> Option<BackgroundDecodeConfig> { pub fn put_config(&self, mut config: BackgroundDecodeConfig) -> Option<BackgroundDecodeConfig> {
@@ -268,10 +282,7 @@ impl BackgroundDecodeManager {
bookmark_id: bookmark.id.clone(), bookmark_id: bookmark.id.clone(),
freq_hz: bookmark.freq_hz, freq_hz: bookmark.freq_hz,
mode: bookmark.mode.clone(), mode: bookmark.mode.clone(),
bandwidth_hz: bookmark bandwidth_hz: bookmark.bandwidth_hz.unwrap_or(0).min(u32::MAX as u64) as u32,
.bandwidth_hz
.unwrap_or(0)
.min(u32::MAX as u64) as u32,
decoder_kinds, decoder_kinds,
} }
} }
@@ -565,7 +576,8 @@ fn bookmark_supported_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
} }
fn channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool { fn channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool {
channel.freq_hz == bookmark.freq_hz && normalized_mode(&channel.mode) == normalized_mode(&bookmark.mode) channel.freq_hz == bookmark.freq_hz
&& normalized_mode(&channel.mode) == normalized_mode(&bookmark.mode)
} }
fn normalized_mode(mode: &str) -> String { fn normalized_mode(mode: &str) -> String {
@@ -117,12 +117,24 @@ impl SchedulerStore {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let db = if path.exists() { let db = if path.exists() {
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) PickleDb::load(
.unwrap_or_else(|_| { path,
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) PickleDbDumpPolicy::AutoDump,
}) SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else { } else {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
}; };
Self { Self {
db: Arc::new(RwLock::new(db)), db: Arc::new(RwLock::new(db)),
@@ -206,10 +218,8 @@ fn sunrise_sunset_today(lat_deg: f64, lon_deg: f64) -> Option<(f64, f64)> {
let lambda = sun_lon - 0.00569 - 0.00478 * omega.to_radians().sin(); let lambda = sun_lon - 0.00569 - 0.00478 * omega.to_radians().sin();
// Obliquity of the ecliptic. // Obliquity of the ecliptic.
let eps0 = 23.0 let eps0 =
+ (26.0 23.0 + (26.0 + (21.448 - jc * (46.8150 + jc * (0.00059 - jc * 0.001813))) / 60.0) / 60.0;
+ (21.448 - jc * (46.8150 + jc * (0.00059 - jc * 0.001813))) / 60.0)
/ 60.0;
let eps = eps0 + 0.00256 * omega.to_radians().cos(); let eps = eps0 + 0.00256 * omega.to_radians().cos();
// Sun's declination. // Sun's declination.
@@ -219,8 +229,7 @@ fn sunrise_sunset_today(lat_deg: f64, lon_deg: f64) -> Option<(f64, f64)> {
let y = (eps.to_radians() / 2.0).tan().powi(2); let y = (eps.to_radians() / 2.0).tan().powi(2);
let l0_rad = l0.to_radians(); let l0_rad = l0.to_radians();
let eot = 4.0 let eot = 4.0
* (y * (2.0 * l0_rad).sin() * (y * (2.0 * l0_rad).sin() - 2.0 * m_rad.sin()
- 2.0 * m_rad.sin()
+ 4.0 * y * m_rad.sin() * (2.0 * l0_rad).cos() + 4.0 * y * m_rad.sin() * (2.0 * l0_rad).cos()
- 0.5 * y * y * (4.0 * l0_rad).sin() - 0.5 * y * y * (4.0 * l0_rad).sin()
- 1.25 * (2.0 * m_rad).sin()) - 1.25 * (2.0 * m_rad).sin())
@@ -228,8 +237,7 @@ fn sunrise_sunset_today(lat_deg: f64, lon_deg: f64) -> Option<(f64, f64)> {
// Hour angle for sunrise/sunset (zenith = 90.833°). // Hour angle for sunrise/sunset (zenith = 90.833°).
let lat_rad = lat_deg.to_radians(); let lat_rad = lat_deg.to_radians();
let cos_ha = ((PI / 2.0 + 0.833_f64.to_radians()).cos()) let cos_ha = ((PI / 2.0 + 0.833_f64.to_radians()).cos()) / (lat_rad.cos() * decl.cos())
/ (lat_rad.cos() * decl.cos())
- lat_rad.tan() * decl.tan(); - lat_rad.tan() * decl.tan();
if !(-1.0..=1.0).contains(&cos_ha) { if !(-1.0..=1.0).contains(&cos_ha) {
@@ -654,7 +662,10 @@ pub fn spawn_scheduler_task(
) )
.await .await
{ {
warn!("scheduler: failed to apply target for '{}': {e}", config.rig_id); warn!(
"scheduler: failed to apply target for '{}': {e}",
config.rig_id
);
continue; continue;
} }
@@ -678,7 +689,11 @@ async fn apply_scheduler_decoders(
let mut want_wspr = false; let mut want_wspr = false;
let mut update_from = |bm: &crate::server::bookmarks::Bookmark| { let mut update_from = |bm: &crate::server::bookmarks::Bookmark| {
for decoder in bm.decoders.iter().map(|item| item.trim().to_ascii_lowercase()) { for decoder in bm
.decoders
.iter()
.map(|item| item.trim().to_ascii_lowercase())
{
match decoder.as_str() { match decoder.as_str() {
"aprs" => want_aprs = true, "aprs" => want_aprs = true,
"hf-aprs" => want_hf_aprs = true, "hf-aprs" => want_hf_aprs = true,
@@ -707,7 +722,10 @@ async fn apply_scheduler_decoders(
for (label, cmd) in desired { for (label, cmd) in desired {
if let Err(e) = scheduler_send(rig_tx, cmd, rig_id.to_string()).await { if let Err(e) = scheduler_send(rig_tx, cmd, rig_id.to_string()).await {
warn!("scheduler: Set{label}DecodeEnabled failed for '{}': {:?}", rig_id, e); warn!(
"scheduler: Set{label}DecodeEnabled failed for '{}': {:?}",
rig_id, e
);
} }
} }
} }
@@ -931,7 +949,9 @@ pub async fn put_scheduler_control(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{timespan_active_entry, timespan_cycle_slot, timespan_active_entries, ScheduleEntry}; use super::{
timespan_active_entries, timespan_active_entry, timespan_cycle_slot, ScheduleEntry,
};
fn entry( fn entry(
id: &str, id: &str,
@@ -6,10 +6,10 @@
mod api; mod api;
#[path = "audio.rs"] #[path = "audio.rs"]
pub mod audio; pub mod audio;
#[path = "background_decode.rs"]
pub mod background_decode;
#[path = "auth.rs"] #[path = "auth.rs"]
pub mod auth; pub mod auth;
#[path = "background_decode.rs"]
pub mod background_decode;
#[path = "bookmarks.rs"] #[path = "bookmarks.rs"]
pub mod bookmarks; pub mod bookmarks;
#[path = "scheduler.rs"] #[path = "scheduler.rs"]
@@ -88,8 +88,7 @@ async fn serve(
); );
let background_decode_path = BackgroundDecodeStore::default_path(); let background_decode_path = BackgroundDecodeStore::default_path();
let background_decode_store = let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
Arc::new(BackgroundDecodeStore::open(&background_decode_path));
let vchan_mgr = Arc::new(ClientChannelManager::new(4)); let vchan_mgr = Arc::new(ClientChannelManager::new(4));
let background_decode_mgr = BackgroundDecodeManager::new( let background_decode_mgr = BackgroundDecodeManager::new(
background_decode_store, background_decode_store,
@@ -9,8 +9,7 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
const INDEX_HTML: &str = include_str!("../assets/web/index.html"); const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js"); pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const DECODE_HISTORY_WORKER_JS: &str = pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
include_str!("../assets/web/decode-history-worker.js");
pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js"); pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js");
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str = pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
include_str!("../assets/web/leaflet-ais-tracksymbol.js"); include_str!("../assets/web/leaflet-ais-tracksymbol.js");
@@ -25,8 +24,7 @@ pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js"); pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js"); pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js"); pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
pub const BACKGROUND_DECODE_JS: &str = pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js");
include_str!("../assets/web/plugins/background-decode.js");
pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js"); pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
pub fn index_html() -> String { pub fn index_html() -> String {
@@ -367,11 +367,7 @@ impl ClientChannelManager {
} }
/// Explicitly delete a channel by UUID (any session may do this). /// Explicitly delete a channel by UUID (any session may do this).
pub fn delete_channel( pub fn delete_channel(&self, rig_id: &str, channel_id: Uuid) -> Result<(), VChanClientError> {
&self,
rig_id: &str,
channel_id: Uuid,
) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap(); let mut rigs = self.rigs.write().unwrap();
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?; let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let pos = channels let pos = channels
@@ -450,7 +446,10 @@ impl ClientChannelManager {
ch.freq_hz = freq_hz; ch.freq_hz = freq_hz;
self.broadcast_change(rig_id, channels); self.broadcast_change(rig_id, channels);
drop(rigs); drop(rigs);
self.send_audio_cmd(VChanAudioCmd::SetFreq { uuid: channel_id, freq_hz }); self.send_audio_cmd(VChanAudioCmd::SetFreq {
uuid: channel_id,
freq_hz,
});
Ok(()) Ok(())
} }
@@ -469,7 +468,10 @@ impl ClientChannelManager {
ch.mode = mode.to_string(); ch.mode = mode.to_string();
self.broadcast_change(rig_id, channels); self.broadcast_change(rig_id, channels);
drop(rigs); drop(rigs);
self.send_audio_cmd(VChanAudioCmd::SetMode { uuid: channel_id, mode: mode.to_string() }); self.send_audio_cmd(VChanAudioCmd::SetMode {
uuid: channel_id,
mode: mode.to_string(),
});
Ok(()) Ok(())
} }
@@ -488,7 +490,10 @@ impl ClientChannelManager {
ch.bandwidth_hz = bandwidth_hz; ch.bandwidth_hz = bandwidth_hz;
self.broadcast_change(rig_id, channels); self.broadcast_change(rig_id, channels);
drop(rigs); drop(rigs);
self.send_audio_cmd(VChanAudioCmd::SetBandwidth { uuid: channel_id, bandwidth_hz }); self.send_audio_cmd(VChanAudioCmd::SetBandwidth {
uuid: channel_id,
bandwidth_hz,
});
Ok(()) Ok(())
} }
@@ -530,12 +535,14 @@ impl ClientChannelManager {
let mut changed = false; let mut changed = false;
let desired_map: HashMap<String, (u64, String, u32, Vec<String>)> = desired let desired_map: HashMap<String, (u64, String, u32, Vec<String>)> = desired
.iter() .iter()
.map(|(bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds)| { .map(
( |(bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds)| {
bookmark_id.clone(), (
(*freq_hz, mode.clone(), *bandwidth_hz, decoder_kinds.clone()), bookmark_id.clone(),
) (*freq_hz, mode.clone(), *bandwidth_hz, decoder_kinds.clone()),
}) )
},
)
.collect(); .collect();
let desired_ids: std::collections::HashSet<&str> = let desired_ids: std::collections::HashSet<&str> =
desired_map.keys().map(String::as_str).collect(); desired_map.keys().map(String::as_str).collect();
@@ -561,7 +568,8 @@ impl ClientChannelManager {
let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else { let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else {
continue; continue;
}; };
let Some((freq_hz, mode, bandwidth_hz, decoder_kinds)) = desired_map.get(bookmark_id) else { let Some((freq_hz, mode, bandwidth_hz, decoder_kinds)) = desired_map.get(bookmark_id)
else {
continue; continue;
}; };
if channel.freq_hz != *freq_hz { if channel.freq_hz != *freq_hz {
@@ -655,6 +655,7 @@ mod tests {
server_latitude: None, server_latitude: None,
server_longitude: None, server_longitude: None,
pskreporter_status: None, pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: false, cw_decode_enabled: false,
+4 -1
View File
@@ -118,7 +118,10 @@ pub async fn read_audio_msg<R: AsyncRead + Unpin>(
if len > limit { if len > limit {
return Err(std::io::Error::new( return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData, std::io::ErrorKind::InvalidData,
format!("audio frame too large: {} bytes (type={:#04x})", len, msg_type), format!(
"audio frame too large: {} bytes (type={:#04x})",
len, msg_type
),
)); ));
} }
let mut payload = vec![0u8; len as usize]; let mut payload = vec![0u8; len as usize];
+7
View File
@@ -29,6 +29,8 @@ pub struct RigState {
pub server_longitude: Option<f64>, pub server_longitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pskreporter_status: Option<String>, pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
#[serde(default)] #[serde(default)]
pub aprs_decode_enabled: bool, pub aprs_decode_enabled: bool,
#[serde(default)] #[serde(default)]
@@ -149,6 +151,7 @@ impl RigState {
server_latitude: None, server_latitude: None,
server_longitude: None, server_longitude: None,
pskreporter_status: None, pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: true, cw_decode_enabled: true,
@@ -217,6 +220,7 @@ impl RigState {
server_latitude: snapshot.server_latitude, server_latitude: snapshot.server_latitude,
server_longitude: snapshot.server_longitude, server_longitude: snapshot.server_longitude,
pskreporter_status: snapshot.pskreporter_status, pskreporter_status: snapshot.pskreporter_status,
aprs_is_status: snapshot.aprs_is_status,
aprs_decode_enabled: snapshot.aprs_decode_enabled, aprs_decode_enabled: snapshot.aprs_decode_enabled,
hf_aprs_decode_enabled: snapshot.hf_aprs_decode_enabled, hf_aprs_decode_enabled: snapshot.hf_aprs_decode_enabled,
cw_decode_enabled: snapshot.cw_decode_enabled, cw_decode_enabled: snapshot.cw_decode_enabled,
@@ -263,6 +267,7 @@ impl RigState {
server_latitude: self.server_latitude, server_latitude: self.server_latitude,
server_longitude: self.server_longitude, server_longitude: self.server_longitude,
pskreporter_status: self.pskreporter_status.clone(), pskreporter_status: self.pskreporter_status.clone(),
aprs_is_status: self.aprs_is_status.clone(),
aprs_decode_enabled: self.aprs_decode_enabled, aprs_decode_enabled: self.aprs_decode_enabled,
hf_aprs_decode_enabled: self.hf_aprs_decode_enabled, hf_aprs_decode_enabled: self.hf_aprs_decode_enabled,
cw_decode_enabled: self.cw_decode_enabled, cw_decode_enabled: self.cw_decode_enabled,
@@ -430,6 +435,8 @@ pub struct RigSnapshot {
pub server_longitude: Option<f64>, pub server_longitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pskreporter_status: Option<String>, pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
#[serde(default)] #[serde(default)]
pub aprs_decode_enabled: bool, pub aprs_decode_enabled: bool,
#[serde(default)] #[serde(default)]
+1
View File
@@ -427,6 +427,7 @@ mod tests {
server_latitude: None, server_latitude: None,
server_longitude: None, server_longitude: None,
pskreporter_status: None, pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false, hf_aprs_decode_enabled: false,
cw_decode_enabled: false, cw_decode_enabled: false,
+7 -1
View File
@@ -127,7 +127,13 @@ pub async fn run_aprsfi_uplink(
// Pre-build the beacon packet (None if beaconing disabled or no coords). // Pre-build the beacon packet (None if beaconing disabled or no coords).
let beacon_packet: Option<String> = if cfg.beacon { let beacon_packet: Option<String> = if cfg.beacon {
match coords { match coords {
Some((lat, lon)) => Some(format_beacon(&callsign, lat, lon, cfg.beacon_symbol_table, cfg.beacon_symbol_code)), Some((lat, lon)) => Some(format_beacon(
&callsign,
lat,
lon,
cfg.beacon_symbol_table,
cfg.beacon_symbol_code,
)),
None => { None => {
warn!( warn!(
"APRS-IS IGate: beacon enabled but no coordinates available \ "APRS-IS IGate: beacon enabled but no coordinates available \
+28
View File
@@ -402,6 +402,25 @@ fn build_rig_task_config(
Some("Disabled".to_string()) Some("Disabled".to_string())
}; };
let aprs_is_status = if rig_cfg.aprsfi.enabled {
let cs = rig_cfg
.aprsfi
.callsign
.as_deref()
.or(callsign.as_deref())
.unwrap_or("");
if cs.trim().is_empty() {
Some("Enabled but inactive (missing callsign)".to_string())
} else {
Some(format!(
"Enabled ({}:{}, {})",
rig_cfg.aprsfi.host, rig_cfg.aprsfi.port, cs
))
}
} else {
Some("Disabled".to_string())
};
rig_task::RigTaskConfig { rig_task::RigTaskConfig {
registry, registry,
rig_id: rig_cfg.id.clone(), rig_id: rig_cfg.id.clone(),
@@ -424,6 +443,7 @@ fn build_rig_task_config(
server_latitude: latitude, server_latitude: latitude,
server_longitude: longitude, server_longitude: longitude,
pskreporter_status, pskreporter_status,
aprs_is_status,
histories, histories,
prebuilt_rig: None, prebuilt_rig: None,
} }
@@ -1007,6 +1027,14 @@ async fn main() -> DynResult<()> {
} else { } else {
Some("Disabled".to_string()) Some("Disabled".to_string())
}; };
initial_state.aprs_is_status = if rig_cfg.aprsfi.enabled {
Some(format!(
"Enabled ({}:{})",
rig_cfg.aprsfi.host, rig_cfg.aprsfi.port
))
} else {
Some("Disabled".to_string())
};
let (state_tx, state_rx) = watch::channel(initial_state); let (state_tx, state_rx) = watch::channel(initial_state);
let mut task_config = build_rig_task_config( let mut task_config = build_rig_task_config(
+3
View File
@@ -46,6 +46,7 @@ pub struct RigTaskConfig {
pub server_latitude: Option<f64>, pub server_latitude: Option<f64>,
pub server_longitude: Option<f64>, pub server_longitude: Option<f64>,
pub pskreporter_status: Option<String>, pub pskreporter_status: Option<String>,
pub aprs_is_status: Option<String>,
/// Per-rig decoder history store. Used by Reset* commands to clear the /// Per-rig decoder history store. Used by Reset* commands to clear the
/// history and by the audio listener to serve history on connection. /// history and by the audio listener to serve history on connection.
pub histories: Arc<DecoderHistories>, pub histories: Arc<DecoderHistories>,
@@ -78,6 +79,7 @@ impl Default for RigTaskConfig {
server_latitude: None, server_latitude: None,
server_longitude: None, server_longitude: None,
pskreporter_status: None, pskreporter_status: None,
aprs_is_status: None,
histories: DecoderHistories::new(), histories: DecoderHistories::new(),
prebuilt_rig: None, prebuilt_rig: None,
} }
@@ -136,6 +138,7 @@ pub async fn run_rig_task(
config.initial_mode.clone(), config.initial_mode.clone(),
); );
state.pskreporter_status = config.pskreporter_status.clone(); state.pskreporter_status = config.pskreporter_status.clone();
state.aprs_is_status = config.aprs_is_status.clone();
// Polling configuration // Polling configuration
let polling = &config.polling; let polling = &config.polling;
@@ -2,8 +2,8 @@
// //
// SPDX-License-Identifier: BSD-2-Clause // SPDX-License-Identifier: BSD-2-Clause
use num_complex::Complex;
use super::DcBlocker; use super::DcBlocker;
use num_complex::Complex;
/// C-QUAM (Compatible Quadrature AM) stereo demodulator. /// C-QUAM (Compatible Quadrature AM) stereo demodulator.
/// ///
@@ -57,8 +57,7 @@ impl CquamDemod {
self.carrier_im = alpha * self.carrier_im + one_minus_alpha * s.im; self.carrier_im = alpha * self.carrier_im + one_minus_alpha * s.im;
// Rotate s by −φ to phase-align I with (1 + m_s) and Q with m_d. // Rotate s by −φ to phase-align I with (1 + m_s) and Q with m_d.
let mag_sq = let mag_sq = self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
let (i_corr, q_corr) = if mag_sq > 1e-8 { let (i_corr, q_corr) = if mag_sq > 1e-8 {
let inv = mag_sq.sqrt().recip(); let inv = mag_sq.sqrt().recip();
let cos_phi = self.carrier_re * inv; let cos_phi = self.carrier_re * inv;
@@ -94,7 +93,10 @@ mod tests {
let out = demod.demodulate_stereo(&samples); let out = demod.demodulate_stereo(&samples);
assert_eq!(out.len(), 512); assert_eq!(out.len(), 512);
for &s in &out { for &s in &out {
assert!(s.abs() < 1e-5, "silence should produce near-zero output, got {s}"); assert!(
s.abs() < 1e-5,
"silence should produce near-zero output, got {s}"
);
} }
} }
@@ -8,9 +8,7 @@ use num_complex::Complex;
/// 7th-order minimax atan approximation for |z| <= 1. /// 7th-order minimax atan approximation for |z| <= 1.
#[cfg(target_arch = "aarch64")] #[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon")] #[target_feature(enable = "neon")]
unsafe fn atan_poly_neon( unsafe fn atan_poly_neon(z: std::arch::aarch64::float32x4_t) -> std::arch::aarch64::float32x4_t {
z: std::arch::aarch64::float32x4_t,
) -> std::arch::aarch64::float32x4_t {
use std::arch::aarch64::*; use std::arch::aarch64::*;
let c0 = vdupq_n_f32(0.999_999_5_f32); let c0 = vdupq_n_f32(0.999_999_5_f32);
let c1 = vdupq_n_f32(-0.333_326_1_f32); let c1 = vdupq_n_f32(-0.333_326_1_f32);
@@ -69,6 +69,11 @@ pub trait IqSource: Send + 'static {
None None
} }
/// Returns `true` when the hardware supports automatic gain control.
fn has_gain_mode(&self) -> bool {
false
}
/// Enable or disable hardware automatic gain control. Default /// Enable or disable hardware automatic gain control. Default
/// implementation is a no-op for sources that do not support AGC. /// implementation is a no-op for sources that do not support AGC.
fn set_gain_mode(&mut self, _automatic: bool) -> Result<(), String> { fn set_gain_mode(&mut self, _automatic: bool) -> Result<(), String> {
@@ -249,7 +254,10 @@ impl SdrPipeline {
channel_if_hz: f64, channel_if_hz: f64,
mode: &RigMode, mode: &RigMode,
bandwidth_hz: u32, bandwidth_hz: u32,
) -> (broadcast::Sender<Vec<f32>>, broadcast::Sender<Vec<Complex<f32>>>) { ) -> (
broadcast::Sender<Vec<f32>>,
broadcast::Sender<Vec<Complex<f32>>>,
) {
const PCM_BROADCAST_CAPACITY: usize = 32; const PCM_BROADCAST_CAPACITY: usize = 32;
const IQ_BROADCAST_CAPACITY: usize = 64; const IQ_BROADCAST_CAPACITY: usize = 64;
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY); let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
@@ -456,9 +464,7 @@ fn iq_read_loop(
// Hold a read lock only for the duration of this block's DSP pass. // Hold a read lock only for the duration of this block's DSP pass.
// Write lock (add/remove channel) waits at most one block (~2 ms). // Write lock (add/remove channel) waits at most one block (~2 ms).
{ {
let dsps = channel_dsps let dsps = channel_dsps.read().expect("channel_dsps RwLock poisoned");
.read()
.expect("channel_dsps RwLock poisoned");
for dsp_arc in dsps.iter() { for dsp_arc in dsps.iter() {
match dsp_arc.lock() { match dsp_arc.lock() {
Ok(mut dsp) => dsp.process_block(samples), Ok(mut dsp) => dsp.process_block(samples),
@@ -272,7 +272,12 @@ impl ChannelDsp {
} else { } else {
(cutoff_hz / self.sdr_sample_rate as f32).min(0.499) (cutoff_hz / self.sdr_sample_rate as f32).min(0.499)
}; };
self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(&self.mode, cutoff_norm), auto_taps(cutoff_norm), IQ_BLOCK_SIZE); self.lpf_iq = BlockFirFilterPair::new(
cutoff_norm,
ssb_shift_norm(&self.mode, cutoff_norm),
auto_taps(cutoff_norm),
IQ_BLOCK_SIZE,
);
let rate_changed = self.decim_factor != next_decim_factor; let rate_changed = self.decim_factor != next_decim_factor;
self.decim_factor = next_decim_factor; self.decim_factor = next_decim_factor;
self.decim_counter = 0; self.decim_counter = 0;
@@ -352,7 +357,12 @@ impl ChannelDsp {
channel_if_hz, channel_if_hz,
demodulator: Demodulator::for_mode(mode), demodulator: Demodulator::for_mode(mode),
mode: mode.clone(), mode: mode.clone(),
lpf_iq: BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(mode, cutoff_norm), auto_taps(cutoff_norm), IQ_BLOCK_SIZE), lpf_iq: BlockFirFilterPair::new(
cutoff_norm,
ssb_shift_norm(mode, cutoff_norm),
auto_taps(cutoff_norm),
IQ_BLOCK_SIZE,
),
sdr_sample_rate, sdr_sample_rate,
audio_sample_rate, audio_sample_rate,
audio_bandwidth_hz, audio_bandwidth_hz,
@@ -109,7 +109,12 @@ type FirKernel = (
/// Setting `shift_norm = +cutoff_norm` produces a one-sided USB filter /// Setting `shift_norm = +cutoff_norm` produces a one-sided USB filter
/// `[0, BW]`; `shift_norm = -cutoff_norm` produces a one-sided LSB filter /// `[0, BW]`; `shift_norm = -cutoff_norm` produces a one-sided LSB filter
/// `[-BW, 0]`; `shift_norm = 0` leaves the kernel symmetric (AM/FM/WFM). /// `[-BW, 0]`; `shift_norm = 0` leaves the kernel symmetric (AM/FM/WFM).
fn build_fir_kernel(cutoff_norm: f32, shift_norm: f32, taps: usize, block_size: usize) -> FirKernel { fn build_fir_kernel(
cutoff_norm: f32,
shift_norm: f32,
taps: usize,
block_size: usize,
) -> FirKernel {
let coeffs = windowed_sinc_coeffs(cutoff_norm, taps); let coeffs = windowed_sinc_coeffs(cutoff_norm, taps);
let fft_size = (block_size + taps - 1).next_power_of_two(); let fft_size = (block_size + taps - 1).next_power_of_two();
@@ -210,8 +215,14 @@ unsafe fn mul_freq_domain_neon(
let (h_re, h_im) = (h_ri.0, h_ri.1); let (h_re, h_im) = (h_ri.0, h_ri.1);
// Complex multiply: out.re = x.re*h.re - x.im*h.im, out.im = x.re*h.im + x.im*h.re // Complex multiply: out.re = x.re*h.re - x.im*h.im, out.im = x.re*h.im + x.im*h.re
let out_re = vmulq_f32(vsubq_f32(vmulq_f32(x_re, h_re), vmulq_f32(x_im, h_im)), scale_v); let out_re = vmulq_f32(
let out_im = vmulq_f32(vaddq_f32(vmulq_f32(x_re, h_im), vmulq_f32(x_im, h_re)), scale_v); vsubq_f32(vmulq_f32(x_re, h_re), vmulq_f32(x_im, h_im)),
scale_v,
);
let out_im = vmulq_f32(
vaddq_f32(vmulq_f32(x_re, h_im), vmulq_f32(x_im, h_re)),
scale_v,
);
// Reinterleave: .0 = [re0,im0,re1,im1], .1 = [re2,im2,re3,im3] // Reinterleave: .0 = [re0,im0,re1,im1], .1 = [re2,im2,re3,im3]
let out = vzipq_f32(out_re, out_im); let out = vzipq_f32(out_re, out_im);
@@ -313,7 +324,8 @@ impl BlockFirFilterPair {
/// `-cutoff_norm` for LSB/CWR. /// `-cutoff_norm` for LSB/CWR.
pub fn new(cutoff_norm: f32, shift_norm: f32, taps: usize, block_size: usize) -> Self { pub fn new(cutoff_norm: f32, shift_norm: f32, taps: usize, block_size: usize) -> Self {
let taps = taps.max(1); let taps = taps.max(1);
let (h_buf, fft_size, fft, ifft) = build_fir_kernel(cutoff_norm, shift_norm, taps, block_size); let (h_buf, fft_size, fft, ifft) =
build_fir_kernel(cutoff_norm, shift_norm, taps, block_size);
Self { Self {
h_freq: h_buf, h_freq: h_buf,
overlap: vec![FftComplex::new(0.0, 0.0); taps.saturating_sub(1)], overlap: vec![FftComplex::new(0.0, 0.0); taps.saturating_sub(1)],
@@ -7,10 +7,10 @@ pub mod dsp;
pub mod real_iq_source; pub mod real_iq_source;
pub mod vchan_impl; pub mod vchan_impl;
use dsp::IqSource as _;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use dsp::IqSource as _;
use trx_core::radio::freq::{Band, Freq}; use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError; use trx_core::rig::response::RigError;
use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel}; use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
@@ -92,8 +92,6 @@ impl SoapySdrRig {
/// - `gain_mode`: `"auto"` or `"manual"`. /// - `gain_mode`: `"auto"` or `"manual"`.
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`. /// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
/// - `max_gain_db`: optional hard ceiling for the applied hardware gain. /// - `max_gain_db`: optional hard ceiling for the applied hardware gain.
/// When `gain_mode == "auto"` hardware AGC is not yet wired, so this
/// value acts as the fallback.
/// - `audio_sample_rate`: output PCM rate (Hz). /// - `audio_sample_rate`: output PCM rate (Hz).
/// - `frame_duration_ms`: output frame length (ms). /// - `frame_duration_ms`: output frame length (ms).
/// - `initial_freq`: initial dial frequency reported by `get_status`. /// - `initial_freq`: initial dial frequency reported by `get_status`.
@@ -137,8 +135,6 @@ impl SoapySdrRig {
max_gain_db, max_gain_db,
); );
let agc_enabled = gain_mode == "auto";
let effective_gain_db = max_gain_db let effective_gain_db = max_gain_db
.map(|max_gain| gain_db.min(max_gain)) .map(|max_gain| gain_db.min(max_gain))
.unwrap_or(gain_db); .unwrap_or(gain_db);
@@ -155,7 +151,7 @@ impl SoapySdrRig {
let hardware_center_hz = initial_freq.hz as i64 - center_offset_hz; let hardware_center_hz = initial_freq.hz as i64 - center_offset_hz;
// Create real IQ source from hardware device. // Create real IQ source from hardware device.
let iq_source = real_iq_source::RealIqSource::new( let mut iq_source = real_iq_source::RealIqSource::new(
args, args,
hardware_center_hz as f64, hardware_center_hz as f64,
sdr_sample_rate as f64, sdr_sample_rate as f64,
@@ -169,6 +165,23 @@ impl SoapySdrRig {
if let Some(lna) = initial_lna_gain_db { if let Some(lna) = initial_lna_gain_db {
tracing::info!("SDR LNA gain element present, initial value: {:.1} dB", lna); tracing::info!("SDR LNA gain element present, initial value: {:.1} dB", lna);
} }
// Enable hardware AGC by default if the device supports it.
let agc_enabled = if iq_source.has_gain_mode() {
match iq_source.set_gain_mode(true) {
Ok(()) => {
tracing::info!("Hardware AGC enabled by default");
true
}
Err(e) => {
tracing::warn!("Failed to enable hardware AGC: {}", e);
false
}
}
} else {
tracing::debug!("Hardware AGC not supported by this device");
false
};
let iq_source: Box<dyn dsp::IqSource> = Box::new(iq_source); let iq_source: Box<dyn dsp::IqSource> = Box::new(iq_source);
let primary_channel_count = channels.len(); let primary_channel_count = channels.len();
@@ -257,10 +270,7 @@ impl SoapySdrRig {
}; };
// Initialise filter state from primary channel config (index 0), or defaults. // Initialise filter state from primary channel config (index 0), or defaults.
let bandwidth_hz = channels let bandwidth_hz = channels.first().map(|&(_, _, bw)| bw).unwrap_or(3000);
.first()
.map(|&(_, _, bw)| bw)
.unwrap_or(3000);
let spectrum_buf = pipeline.spectrum_buf.clone(); let spectrum_buf = pipeline.spectrum_buf.clone();
let retune_cmd = pipeline.retune_cmd.clone(); let retune_cmd = pipeline.retune_cmd.clone();
@@ -359,10 +369,7 @@ impl SoapySdrRig {
let dsps = self.pipeline.channel_dsps.read().unwrap(); let dsps = self.pipeline.channel_dsps.read().unwrap();
for idx in [ais_a_idx, ais_b_idx] { for idx in [ais_a_idx, ais_b_idx] {
if let Some(dsp_arc) = dsps.get(idx) { if let Some(dsp_arc) = dsps.get(idx) {
dsp_arc dsp_arc.lock().unwrap().set_filter(self.bandwidth_hz);
.lock()
.unwrap()
.set_filter(self.bandwidth_hz);
} }
} }
} }
@@ -749,10 +756,7 @@ impl RigCat for SoapySdrRig {
{ {
let dsps = self.pipeline.channel_dsps.read().unwrap(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) { if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
dsp_arc dsp_arc.lock().unwrap().set_filter(bandwidth_hz);
.lock()
.unwrap()
.set_filter(bandwidth_hz);
} }
} }
self.apply_ais_channel_filters(); self.apply_ais_channel_filters();
@@ -191,6 +191,12 @@ impl IqSource for RealIqSource {
.ok() .ok()
} }
fn has_gain_mode(&self) -> bool {
self.device
.has_gain_mode(soapysdr::Direction::Rx, 0)
.unwrap_or(false)
}
fn set_gain_mode(&mut self, automatic: bool) -> Result<(), String> { fn set_gain_mode(&mut self, automatic: bool) -> Result<(), String> {
self.device self.device
.set_gain_mode(soapysdr::Direction::Rx, 0, automatic) .set_gain_mode(soapysdr::Direction::Rx, 0, automatic)
@@ -100,11 +100,7 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS), /// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels. /// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4). /// - `max_total`: maximum total channels including primary (e.g. 4).
pub fn new( pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> Self {
pipeline: Arc<SdrPipeline>,
fixed_slot_count: usize,
max_total: usize,
) -> Self {
// Seed the channel list with a synthetic primary-channel entry. // Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0). // We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline let primary_pcm_tx = pipeline
@@ -177,9 +173,9 @@ impl SdrVirtualChannelManager {
} }
let bandwidth_hz = default_bandwidth_hz(mode); let bandwidth_hz = default_bandwidth_hz(mode);
let (pcm_tx, iq_tx) = let (pcm_tx, iq_tx) = self
self.pipeline .pipeline
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz); .add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
let pipeline_slot = self let pipeline_slot = self
.pipeline .pipeline