Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc64c51fd0 | |||
| 723da3f7ed | |||
| 7cf829ef52 | |||
| 2517ed0b29 | |||
| 9019acee0e | |||
| cf4c262456 | |||
| 7527770c0c | |||
| b533d704a1 |
@@ -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,
|
||||||
|
|||||||
@@ -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 0–60 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,8 +320,7 @@ 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) => {
|
||||||
|
|||||||
@@ -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,10 +297,7 @@ 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,
|
|
||||||
writer.write_all(payload.as_bytes()),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
||||||
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
||||||
@@ -347,10 +340,7 @@ 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,
|
|
||||||
writer.write_all(payload.as_bytes()),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
RigError::communication(format!("write timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
RigError::communication(format!("write timed out after {:?}", SPECTRUM_IO_TIMEOUT))
|
||||||
@@ -443,10 +433,7 @@ 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,
|
|
||||||
writer.write_all(payload.as_bytes()),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
|
||||||
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = href;
|
||||||
a.download = fileName;
|
|
||||||
a.click();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
}, "image/png");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = canvas.toDataURL("image/png");
|
|
||||||
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(
|
||||||
|
path,
|
||||||
|
PickleDbDumpPolicy::AutoDump,
|
||||||
|
SerializationMethod::Json,
|
||||||
|
)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
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,7 +172,9 @@ 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
|
||||||
|
.get(rig_id)
|
||||||
|
.unwrap_or_else(|| BackgroundDecodeConfig {
|
||||||
rig_id: rig_id.to_string(),
|
rig_id: rig_id.to_string(),
|
||||||
enabled: false,
|
enabled: false,
|
||||||
bookmark_ids: Vec::new(),
|
bookmark_ids: Vec::new(),
|
||||||
@@ -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(
|
||||||
|
path,
|
||||||
|
PickleDbDumpPolicy::AutoDump,
|
||||||
|
SerializationMethod::Json,
|
||||||
|
)
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
|
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(),
|
bookmark_id.clone(),
|
||||||
(*freq_hz, mode.clone(), *bandwidth_hz, decoder_kinds.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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,8 +173,8 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user