Compare commits

...

8 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:39:00 +01:00
sjg b533d704a1 [style](trx-rs): reformat codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:36:11 +01:00
39 changed files with 579 additions and 300 deletions
+10 -6
View File
@@ -25,6 +25,11 @@ const DT_SEARCH_STEP_SAMPLES: isize = (WSPR_SAMPLE_RATE as isize) / 2;
// Number of top frequency candidates to try full decode on
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].
/// The LSB of each received symbol should match this pattern.
#[rustfmt::skip]
@@ -123,7 +128,10 @@ impl WsprDecoder {
let mut results = Vec::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 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,
WSPR_SAMPLE_RATE as f32,
);
let p1 = goertzel_power(
frame,
base_hz + TONE_SPACING_HZ,
WSPR_SAMPLE_RATE as f32,
);
let p1 = goertzel_power(frame, base_hz + TONE_SPACING_HZ, WSPR_SAMPLE_RATE as f32);
let p3 = goertzel_power(
frame,
base_hz + 3.0 * TONE_SPACING_HZ,
+19 -2
View File
@@ -141,9 +141,12 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
power_code = (power_code << 1) | b as u32;
}
// power_code is the raw dBm value; valid WSPR levels are 060 dBm.
// WSPR only permits specific power levels (dBm).
const VALID_POWER: [i32; 19] = [
0, 3, 7, 10, 13, 17, 20, 23, 27, 30, 33, 37, 40, 43, 47, 50, 53, 57, 60,
];
let power_dbm = power_code as i32;
if !(0..=60).contains(&power_dbm) {
if !VALID_POWER.contains(&power_dbm) {
return None;
}
@@ -182,9 +185,23 @@ fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
.trim()
.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()) {
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.
// M1 = (179 - 10*loc1 - loc3)*180 + 10*loc2 + loc4
+9 -7
View File
@@ -25,11 +25,11 @@ use trx_core::audio::{
parse_vchan_audio_frame, parse_vchan_uuid_msg, read_audio_msg, write_audio_msg,
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_HF_APRS_DECODE,
AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW,
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME,
AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::DecodedMessage;
use trx_frontend::VChanAudioCmd;
@@ -195,7 +195,8 @@ async fn handle_audio_connection(
}
// Re-apply non-default bandwidth after re-subscribing.
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 Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_BW, &payload).await {
warn!("Audio vchan reconnect BW write failed: {}", e);
@@ -209,7 +210,8 @@ async fn handle_audio_connection(
// Spawn RX read task
let rx_tx = rx_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 mut rx_handle = tokio::spawn(async move {
loop {
+3 -7
View File
@@ -395,9 +395,7 @@ impl ClientConfig {
);
}
if self.frontends.http.decode_history_retention_min == 0 {
return Err(
"[frontends.http].decode_history_retention_min must be > 0".to_string(),
);
return Err("[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 {
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_usable_span_ratio, 0.92);
assert_eq!(config.frontends.http.decode_history_retention_min, 1440);
assert!(
config
assert!(config
.frontends
.http
.decode_history_retention_min_by_rig
.is_empty()
);
.is_empty());
assert_eq!(config.frontends.rigctl.port, 4532);
assert!(config.frontends.http_json.enabled);
assert_eq!(config.frontends.http_json.port, 0);
+7 -6
View File
@@ -185,8 +185,11 @@ async fn async_init() -> DynResult<AppState> {
cfg.frontends.http.spectrum_usable_span_ratio;
frontend_runtime.http_decode_history_retention_min =
cfg.frontends.http.decode_history_retention_min;
frontend_runtime.http_decode_history_retention_min_by_rig =
cfg.frontends.http.decode_history_retention_min_by_rig.clone();
frontend_runtime.http_decode_history_retention_min_by_rig = cfg
.frontends
.http
.decode_history_retention_min_by_rig
.clone();
// Resolve remote URL: CLI > config [remote] section > error
let remote_url = cli
@@ -305,8 +308,7 @@ async fn async_init() -> DynResult<AppState> {
frontend_runtime.decode_rx = Some(decode_tx.clone());
// Virtual-channel audio: shared broadcaster map + command channel.
let (vchan_cmd_tx, vchan_cmd_rx) =
mpsc::unbounded_channel::<trx_frontend::VChanAudioCmd>();
let (vchan_cmd_tx, vchan_cmd_rx) = mpsc::unbounded_channel::<trx_frontend::VChanAudioCmd>();
*frontend_runtime.vchan_audio_cmd.lock().unwrap() = Some(vchan_cmd_tx);
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 ft8_history = frontend_runtime.ft8_history.clone();
let wspr_history = frontend_runtime.wspr_history.clone();
let replay_history_sink: Arc<dyn Fn(DecodedMessage) + Send + Sync> =
Arc::new(move |msg| {
let replay_history_sink: Arc<dyn Fn(DecodedMessage) + Send + Sync> = Arc::new(move |msg| {
let now = std::time::Instant::now();
match msg {
DecodedMessage::Ais(mut message) => {
+6 -18
View File
@@ -68,10 +68,7 @@ pub async fn run_remote_client(
) -> RigResult<()> {
// Spectrum polling runs on its own dedicated TCP connection so it never
// blocks state polls or user commands on the main connection.
let spectrum_task = tokio::spawn(run_spectrum_connection(
config.clone(),
shutdown_rx.clone(),
));
let spectrum_task = tokio::spawn(run_spectrum_connection(config.clone(), shutdown_rx.clone()));
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) {
warn!("Spectrum TCP_NODELAY failed: {}", e);
}
if let Err(e) =
handle_spectrum_connection(&config, stream, &mut shutdown_rx).await
if let Err(e) = handle_spectrum_connection(&config, stream, &mut shutdown_rx).await
{
warn!("Spectrum connection dropped: {}", e);
}
@@ -301,10 +297,7 @@ async fn send_command(
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(
IO_TIMEOUT,
writer.write_all(payload.as_bytes()),
)
time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
.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)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(
SPECTRUM_IO_TIMEOUT,
writer.write_all(payload.as_bytes()),
)
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| {
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}")))?;
payload.push('\n');
time::timeout(
IO_TIMEOUT,
writer.write_all(payload.as_bytes()),
)
time::timeout(IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))?
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
@@ -778,6 +765,7 @@ mod tests {
server_latitude: None,
server_longitude: None,
pskreporter_status: Some("Disabled".to_string()),
aprs_is_status: Some("Disabled".to_string()),
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
+6 -2
View File
@@ -3,9 +3,9 @@
// SPDX-License-Identifier: BSD-2-Clause
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::RwLock;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::RwLock;
use std::sync::{Arc, Mutex};
use std::time::Instant;
@@ -95,7 +95,11 @@ pub struct SharedSpectrum {
impl SharedSpectrum {
/// 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
.as_ref()
.and_then(|f| f.rds.as_ref())
@@ -403,6 +403,7 @@ mod tests {
server_latitude: None,
server_longitude: None,
pskreporter_status: Some("Disabled".to_string()),
aprs_is_status: Some("Disabled".to_string()),
aprs_decode_enabled: false,
hf_aprs_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 overviewCanvas = document.getElementById("overview-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"
? createTrxWebGlRenderer(overviewCanvas, { alpha: true })
? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions)
: null;
const signalOverlayGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(signalOverlayCanvas, { alpha: true })
? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions)
: null;
const signalVisualBlockEl = document.querySelector(".signal-visual-block");
const signalSplitControlEl = document.getElementById("signal-split-control");
@@ -2875,6 +2877,7 @@ function render(update) {
const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn");
if (ft8ToggleBtn) {
const ft8On = !!update.ft8_decode_enabled;
ft8ToggleBtn.dataset.enabled = ft8On ? "true" : "false";
ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8";
ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : "";
ft8ToggleBtn.style.color = ft8On ? "#00d17f" : "";
@@ -2882,6 +2885,7 @@ function render(update) {
const ft4ToggleBtn = document.getElementById("ft4-decode-toggle-btn");
if (ft4ToggleBtn) {
const ft4On = !!update.ft4_decode_enabled;
ft4ToggleBtn.dataset.enabled = ft4On ? "true" : "false";
ft4ToggleBtn.textContent = ft4On ? "Disable FT4" : "Enable FT4";
ft4ToggleBtn.style.borderColor = ft4On ? "#00d17f" : "";
ft4ToggleBtn.style.color = ft4On ? "#00d17f" : "";
@@ -2889,6 +2893,7 @@ function render(update) {
const ft2ToggleBtn = document.getElementById("ft2-decode-toggle-btn");
if (ft2ToggleBtn) {
const ft2On = !!update.ft2_decode_enabled;
ft2ToggleBtn.dataset.enabled = ft2On ? "true" : "false";
ft2ToggleBtn.textContent = ft2On ? "Disable FT2" : "Enable FT2";
ft2ToggleBtn.style.borderColor = ft2On ? "#00d17f" : "";
ft2ToggleBtn.style.color = ft2On ? "#00d17f" : "";
@@ -2896,6 +2901,7 @@ function render(update) {
const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn");
if (wsprToggleBtn) {
const wsprOn = !!update.wspr_decode_enabled;
wsprToggleBtn.dataset.enabled = wsprOn ? "true" : "false";
wsprToggleBtn.textContent = wsprOn ? "Disable WSPR" : "Enable WSPR";
wsprToggleBtn.style.borderColor = wsprOn ? "#00d17f" : "";
wsprToggleBtn.style.color = wsprOn ? "#00d17f" : "";
@@ -2903,6 +2909,7 @@ function render(update) {
const hfAprsToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
if (hfAprsToggleBtn) {
const hfAprsOn = !!update.hf_aprs_decode_enabled;
hfAprsToggleBtn.dataset.enabled = hfAprsOn ? "true" : "false";
hfAprsToggleBtn.textContent = hfAprsOn ? "Disable HF APRS" : "Enable HF APRS";
hfAprsToggleBtn.style.borderColor = hfAprsOn ? "#00d17f" : "";
hfAprsToggleBtn.style.color = hfAprsOn ? "#00d17f" : "";
@@ -3014,6 +3021,9 @@ function render(update) {
if (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) {
const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" ");
if (parts) document.getElementById("about-rig-info").textContent = parts;
@@ -3247,6 +3257,16 @@ async function postPath(path) {
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) {
if (!selectEl || !selectEl.value) {
showHint("No rig selected", 1500);
@@ -4873,7 +4893,7 @@ function renderMapLocatorChipRow(container, items, selectedSet, kind) {
});
if (kind === "source") {
if (isDefaultSourceState) {
helperText = "Default: all non-bookmark sources visible";
helperText = "Click a source to isolate it";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
@@ -5499,7 +5519,15 @@ function initAprsMap() {
const key = String(chip.dataset.filterKey || "");
if (!key) return;
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) {
const entry = stationMarkers.get(String(selectedAprsTrackCall));
if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) {
@@ -7905,7 +7933,7 @@ window.addEventListener("beforeunload", () => {
// ── Spectrum display ─────────────────────────────────────────────────────────
const spectrumCanvas = document.getElementById("spectrum-canvas");
const spectrumGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(spectrumCanvas, { alpha: true })
? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions)
: null;
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
@@ -9088,6 +9116,16 @@ function buildSpectrumSnapshotCanvas() {
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
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 dpr = window.devicePixelRatio || 1;
const out = document.createElement("canvas");
@@ -9138,35 +9176,55 @@ function buildSpectrumSnapshotCanvas() {
return out;
}
function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return;
if (typeof canvas.toBlob === "function") {
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
function clickCanvasDownload(href, fileName) {
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}, "image/png");
return;
}
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.href = href;
a.download = fileName;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
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();
if (!snapshotCanvas) {
showHint("Spectrum view not ready", 1300);
return;
return false;
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
showHint("Spectrum screenshot saved", 1500);
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved;
}
function shouldIgnoreGlobalShortcut(target) {
@@ -9178,13 +9236,13 @@ function shouldIgnoreGlobalShortcut(target) {
}
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 (shouldIgnoreGlobalShortcut(event.target)) return;
if ((event.key || "").toLowerCase() !== "s") return;
event.preventDefault();
captureSpectrumScreenshot();
});
void captureSpectrumScreenshot();
}, { capture: true });
// ── Zoom helpers ──────────────────────────────────────────────────────────────
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 clients</td><td id="about-rigctl-clients">--</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>Connected clients</td><td id="about-clients">--</td></tr>
</table>
@@ -179,8 +179,14 @@ if (ft2FilterInput) {
});
}
document.getElementById("ft2-decode-toggle-btn")?.addEventListener("click", async () => {
try { await postPath("/toggle_ft2_decode"); } catch (e) { console.error("FT2 toggle failed", e); }
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
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 () => {
@@ -179,8 +179,14 @@ if (ft4FilterInput) {
});
}
document.getElementById("ft4-decode-toggle-btn")?.addEventListener("click", async () => {
try { await postPath("/toggle_ft4_decode"); } catch (e) { console.error("FT4 toggle failed", e); }
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
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 () => {
@@ -447,8 +447,14 @@ if (ft8MessagesEl) {
});
}
document.getElementById("ft8-decode-toggle-btn").addEventListener("click", async () => {
try { await postPath("/toggle_ft8_decode"); } catch (e) { console.error("FT8 toggle failed", e); }
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
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 () => {
@@ -372,8 +372,14 @@ window.restoreHfAprsHistory = function(packets) {
window.onServerHfAprsBatch(packets);
};
document.getElementById("hf-aprs-decode-toggle-btn")?.addEventListener("click", async () => {
try { await postPath("/toggle_hf_aprs_decode"); } catch (e) { console.error("HF APRS toggle failed", e); }
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
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 () => {
@@ -111,6 +111,7 @@ async function vchanTakeSchedulerControl() {
console.error("scheduler control takeover failed", e);
}
}
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
// Called by app.js when the SSE `session` event arrives.
function vchanHandleSession(data) {
@@ -255,8 +255,14 @@ if (wsprMessagesEl) {
});
}
document.getElementById("wspr-decode-toggle-btn").addEventListener("click", async () => {
try { await postPath("/toggle_wspr_decode"); } catch (e) { console.error("WSPR toggle failed", e); }
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
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 () => {
@@ -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-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; }
#content { display: flex; flex-direction: column; gap: 1.1rem; min-height: 0; overflow: visible; }
.tab-panel { flex: 1 1 auto; 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; display: flex; flex-direction: column; }
.sub-tab-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.tab-bar {
display: flex;
align-items: center;
@@ -2125,7 +2126,7 @@ body.map-fake-fullscreen-active {
#ft8-messages,
#ft4-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:last-child { border-bottom: none; }
.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;
out.push(T[((n >> 18) & 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() > 2 { T[(n & 63) as usize] } else { b'=' });
out.push(if chunk.len() > 1 {
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.
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.
let mut extra = serde_json::Map::new();
extra.insert("clients".into(), serde_json::json!(meta.http_clients));
extra.insert("rigctl_clients".into(), 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(
"rigctl_clients".into(),
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));
if let Some(v) = meta.owner_callsign { extra.insert("owner_callsign".into(), serde_json::json!(v)); }
if let Some(v) = meta.owner_website_url { extra.insert("owner_website_url".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)); }
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));
if let Some(v) = meta.owner_callsign {
extra.insert("owner_callsign".into(), serde_json::json!(v));
}
if let Some(v) = meta.owner_website_url {
extra.insert("owner_website_url".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));
}
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(
"decode_history_retention_min".into(),
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.
let extra_json = match serde_json::to_string(&extra) {
@@ -328,9 +366,7 @@ pub async fn events(
let scheduler_control = scheduler_control_updates.clone();
async move {
state.snapshot().and_then(|v| {
if let Ok(Some(rig_id)) =
context.remote_active_rig_id.lock().map(|g| g.clone())
{
if let Ok(Some(rig_id)) = context.remote_active_rig_id.lock().map(|g| g.clone()) {
vchan.update_primary(
&rig_id,
v.status.freq.hz,
@@ -367,9 +403,8 @@ pub async fn events(
if let Some(colon) = msg.find(':') {
let rig_id = &msg[..colon];
let channels_json = &msg[colon + 1..];
let payload = format!(
"{{\"rig_id\":\"{rig_id}\",\"channels\":{channels_json}}}"
);
let payload =
format!("{{\"rig_id\":\"{rig_id}\",\"channels\":{channels_json}}}");
return Some((
Ok::<Bytes, Error>(Bytes::from(format!(
"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
/// with opening the SSE connection and drains it in the background.
#[get("/decode/history")]
pub async fn decode_history(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> impl Responder {
pub async fn decode_history(context: web::Data<Arc<FrontendRuntimeContext>>) -> impl Responder {
if context.decode_rx.is_none() {
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();
match vchan_mgr.delete_channel(&rig_id, channel_id) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => {
HttpResponse::NotFound().finish()
}
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
Err(crate::server::vchan::VChanClientError::Permanent) => {
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();
match vchan_mgr.set_channel_freq(&rig_id, channel_id, body.freq_hz) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => {
HttpResponse::NotFound().finish()
}
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
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();
match vchan_mgr.set_channel_bandwidth(&rig_id, channel_id, body.bandwidth_hz) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => {
HttpResponse::NotFound().finish()
}
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
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();
match vchan_mgr.set_channel_mode(&rig_id, channel_id, &body.mode) {
Ok(()) => HttpResponse::Ok().finish(),
Err(crate::server::vchan::VChanClientError::NotFound) => {
HttpResponse::NotFound().finish()
}
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
@@ -1783,14 +1808,20 @@ async fn ft8_js() -> impl Responder {
#[get("/ft4.js")]
async fn ft4_js() -> impl Responder {
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)
}
#[get("/ft2.js")]
async fn ft2_js() -> impl Responder {
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)
}
@@ -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> {
@@ -2018,7 +2056,8 @@ async fn apply_selected_channel(
let Some(bookmark) = bookmark_store.get(bookmark_id) else {
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 = [
RigCommand::SetAprsDecodeEnabled(want_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_longitude: state.server_longitude,
pskreporter_status: state.pskreporter_status,
aprs_is_status: state.aprs_is_status,
aprs_decode_enabled: state.aprs_decode_enabled,
hf_aprs_decode_enabled: state.hf_aprs_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)
}
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);
while let Some((ts, _)) = history.front() {
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);
while let Some((ts, _)) = history.front() {
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);
while let Some((ts, _)) = history.front() {
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);
while let Some((ts, _)) = history.front() {
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);
while let Some((ts, _)) = history.front() {
if *ts >= cutoff {
@@ -85,12 +85,24 @@ impl BackgroundDecodeStore {
let _ = std::fs::create_dir_all(parent);
}
let db = if path.exists() {
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::load(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
};
Self {
db: Arc::new(RwLock::new(db)),
@@ -160,7 +172,9 @@ impl BackgroundDecodeManager {
}
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(),
enabled: false,
bookmark_ids: Vec::new(),
@@ -268,10 +282,7 @@ impl BackgroundDecodeManager {
bookmark_id: bookmark.id.clone(),
freq_hz: bookmark.freq_hz,
mode: bookmark.mode.clone(),
bandwidth_hz: bookmark
.bandwidth_hz
.unwrap_or(0)
.min(u32::MAX as u64) as u32,
bandwidth_hz: bookmark.bandwidth_hz.unwrap_or(0).min(u32::MAX as u64) as u32,
decoder_kinds,
}
}
@@ -565,7 +576,8 @@ fn bookmark_supported_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
}
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 {
@@ -117,12 +117,24 @@ impl SchedulerStore {
let _ = std::fs::create_dir_all(parent);
}
let db = if path.exists() {
PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::load(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
.unwrap_or_else(|_| {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
})
} else {
PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json)
PickleDb::new(
path,
PickleDbDumpPolicy::AutoDump,
SerializationMethod::Json,
)
};
Self {
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();
// Obliquity of the ecliptic.
let eps0 = 23.0
+ (26.0
+ (21.448 - jc * (46.8150 + jc * (0.00059 - jc * 0.001813))) / 60.0)
/ 60.0;
let eps0 =
23.0 + (26.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();
// 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 l0_rad = l0.to_radians();
let eot = 4.0
* (y * (2.0 * l0_rad).sin()
- 2.0 * m_rad.sin()
* (y * (2.0 * l0_rad).sin() - 2.0 * m_rad.sin()
+ 4.0 * y * m_rad.sin() * (2.0 * l0_rad).cos()
- 0.5 * y * y * (4.0 * l0_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°).
let lat_rad = lat_deg.to_radians();
let cos_ha = ((PI / 2.0 + 0.833_f64.to_radians()).cos())
/ (lat_rad.cos() * decl.cos())
let cos_ha = ((PI / 2.0 + 0.833_f64.to_radians()).cos()) / (lat_rad.cos() * decl.cos())
- lat_rad.tan() * decl.tan();
if !(-1.0..=1.0).contains(&cos_ha) {
@@ -654,7 +662,10 @@ pub fn spawn_scheduler_task(
)
.await
{
warn!("scheduler: failed to apply target for '{}': {e}", config.rig_id);
warn!(
"scheduler: failed to apply target for '{}': {e}",
config.rig_id
);
continue;
}
@@ -678,7 +689,11 @@ async fn apply_scheduler_decoders(
let mut want_wspr = false;
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() {
"aprs" => want_aprs = true,
"hf-aprs" => want_hf_aprs = true,
@@ -707,7 +722,10 @@ async fn apply_scheduler_decoders(
for (label, cmd) in desired {
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)]
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(
id: &str,
@@ -6,10 +6,10 @@
mod api;
#[path = "audio.rs"]
pub mod audio;
#[path = "background_decode.rs"]
pub mod background_decode;
#[path = "auth.rs"]
pub mod auth;
#[path = "background_decode.rs"]
pub mod background_decode;
#[path = "bookmarks.rs"]
pub mod bookmarks;
#[path = "scheduler.rs"]
@@ -88,8 +88,7 @@ async fn serve(
);
let background_decode_path = BackgroundDecodeStore::default_path();
let background_decode_store =
Arc::new(BackgroundDecodeStore::open(&background_decode_path));
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
let vchan_mgr = Arc::new(ClientChannelManager::new(4));
let background_decode_mgr = BackgroundDecodeManager::new(
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");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const DECODE_HISTORY_WORKER_JS: &str =
include_str!("../assets/web/decode-history-worker.js");
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js");
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
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 BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
pub const BACKGROUND_DECODE_JS: &str =
include_str!("../assets/web/plugins/background-decode.js");
pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js");
pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
pub fn index_html() -> String {
@@ -367,11 +367,7 @@ impl ClientChannelManager {
}
/// Explicitly delete a channel by UUID (any session may do this).
pub fn delete_channel(
&self,
rig_id: &str,
channel_id: Uuid,
) -> Result<(), VChanClientError> {
pub fn delete_channel(&self, rig_id: &str, channel_id: Uuid) -> Result<(), VChanClientError> {
let mut rigs = self.rigs.write().unwrap();
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
let pos = channels
@@ -450,7 +446,10 @@ impl ClientChannelManager {
ch.freq_hz = freq_hz;
self.broadcast_change(rig_id, channels);
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(())
}
@@ -469,7 +468,10 @@ impl ClientChannelManager {
ch.mode = mode.to_string();
self.broadcast_change(rig_id, channels);
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(())
}
@@ -488,7 +490,10 @@ impl ClientChannelManager {
ch.bandwidth_hz = bandwidth_hz;
self.broadcast_change(rig_id, channels);
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(())
}
@@ -530,12 +535,14 @@ impl ClientChannelManager {
let mut changed = false;
let desired_map: HashMap<String, (u64, String, u32, Vec<String>)> = desired
.iter()
.map(|(bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds)| {
.map(
|(bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds)| {
(
bookmark_id.clone(),
(*freq_hz, mode.clone(), *bandwidth_hz, decoder_kinds.clone()),
)
})
},
)
.collect();
let desired_ids: std::collections::HashSet<&str> =
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 {
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;
};
if channel.freq_hz != *freq_hz {
@@ -655,6 +655,7 @@ mod tests {
server_latitude: None,
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
+4 -1
View File
@@ -118,7 +118,10 @@ pub async fn read_audio_msg<R: AsyncRead + Unpin>(
if len > limit {
return Err(std::io::Error::new(
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];
+7
View File
@@ -29,6 +29,8 @@ pub struct RigState {
pub server_longitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
#[serde(default)]
pub aprs_decode_enabled: bool,
#[serde(default)]
@@ -149,6 +151,7 @@ impl RigState {
server_latitude: None,
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: true,
@@ -217,6 +220,7 @@ impl RigState {
server_latitude: snapshot.server_latitude,
server_longitude: snapshot.server_longitude,
pskreporter_status: snapshot.pskreporter_status,
aprs_is_status: snapshot.aprs_is_status,
aprs_decode_enabled: snapshot.aprs_decode_enabled,
hf_aprs_decode_enabled: snapshot.hf_aprs_decode_enabled,
cw_decode_enabled: snapshot.cw_decode_enabled,
@@ -263,6 +267,7 @@ impl RigState {
server_latitude: self.server_latitude,
server_longitude: self.server_longitude,
pskreporter_status: self.pskreporter_status.clone(),
aprs_is_status: self.aprs_is_status.clone(),
aprs_decode_enabled: self.aprs_decode_enabled,
hf_aprs_decode_enabled: self.hf_aprs_decode_enabled,
cw_decode_enabled: self.cw_decode_enabled,
@@ -430,6 +435,8 @@ pub struct RigSnapshot {
pub server_longitude: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pskreporter_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aprs_is_status: Option<String>,
#[serde(default)]
pub aprs_decode_enabled: bool,
#[serde(default)]
+1
View File
@@ -427,6 +427,7 @@ mod tests {
server_latitude: None,
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
+7 -1
View File
@@ -127,7 +127,13 @@ pub async fn run_aprsfi_uplink(
// Pre-build the beacon packet (None if beaconing disabled or no coords).
let beacon_packet: Option<String> = if cfg.beacon {
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 => {
warn!(
"APRS-IS IGate: beacon enabled but no coordinates available \
+28
View File
@@ -402,6 +402,25 @@ fn build_rig_task_config(
Some("Disabled".to_string())
};
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 {
registry,
rig_id: rig_cfg.id.clone(),
@@ -424,6 +443,7 @@ fn build_rig_task_config(
server_latitude: latitude,
server_longitude: longitude,
pskreporter_status,
aprs_is_status,
histories,
prebuilt_rig: None,
}
@@ -1007,6 +1027,14 @@ async fn main() -> DynResult<()> {
} else {
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 mut task_config = build_rig_task_config(
+3
View File
@@ -46,6 +46,7 @@ pub struct RigTaskConfig {
pub server_latitude: Option<f64>,
pub server_longitude: Option<f64>,
pub pskreporter_status: Option<String>,
pub aprs_is_status: Option<String>,
/// Per-rig decoder history store. Used by Reset* commands to clear the
/// history and by the audio listener to serve history on connection.
pub histories: Arc<DecoderHistories>,
@@ -78,6 +79,7 @@ impl Default for RigTaskConfig {
server_latitude: None,
server_longitude: None,
pskreporter_status: None,
aprs_is_status: None,
histories: DecoderHistories::new(),
prebuilt_rig: None,
}
@@ -136,6 +138,7 @@ pub async fn run_rig_task(
config.initial_mode.clone(),
);
state.pskreporter_status = config.pskreporter_status.clone();
state.aprs_is_status = config.aprs_is_status.clone();
// Polling configuration
let polling = &config.polling;
@@ -2,8 +2,8 @@
//
// SPDX-License-Identifier: BSD-2-Clause
use num_complex::Complex;
use super::DcBlocker;
use num_complex::Complex;
/// 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;
// Rotate s by −φ to phase-align I with (1 + m_s) and Q with m_d.
let mag_sq =
self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
let mag_sq = self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
let (i_corr, q_corr) = if mag_sq > 1e-8 {
let inv = mag_sq.sqrt().recip();
let cos_phi = self.carrier_re * inv;
@@ -94,7 +93,10 @@ mod tests {
let out = demod.demodulate_stereo(&samples);
assert_eq!(out.len(), 512);
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.
#[cfg(target_arch = "aarch64")]
#[target_feature(enable = "neon")]
unsafe fn atan_poly_neon(
z: std::arch::aarch64::float32x4_t,
) -> std::arch::aarch64::float32x4_t {
unsafe fn atan_poly_neon(z: std::arch::aarch64::float32x4_t) -> std::arch::aarch64::float32x4_t {
use std::arch::aarch64::*;
let c0 = vdupq_n_f32(0.999_999_5_f32);
let c1 = vdupq_n_f32(-0.333_326_1_f32);
@@ -69,6 +69,11 @@ pub trait IqSource: Send + 'static {
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
/// implementation is a no-op for sources that do not support AGC.
fn set_gain_mode(&mut self, _automatic: bool) -> Result<(), String> {
@@ -249,7 +254,10 @@ impl SdrPipeline {
channel_if_hz: f64,
mode: &RigMode,
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 IQ_BROADCAST_CAPACITY: usize = 64;
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.
// Write lock (add/remove channel) waits at most one block (~2 ms).
{
let dsps = channel_dsps
.read()
.expect("channel_dsps RwLock poisoned");
let dsps = channel_dsps.read().expect("channel_dsps RwLock poisoned");
for dsp_arc in dsps.iter() {
match dsp_arc.lock() {
Ok(mut dsp) => dsp.process_block(samples),
@@ -272,7 +272,12 @@ impl ChannelDsp {
} else {
(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;
self.decim_factor = next_decim_factor;
self.decim_counter = 0;
@@ -352,7 +357,12 @@ impl ChannelDsp {
channel_if_hz,
demodulator: Demodulator::for_mode(mode),
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,
audio_sample_rate,
audio_bandwidth_hz,
@@ -109,7 +109,12 @@ type FirKernel = (
/// Setting `shift_norm = +cutoff_norm` produces a one-sided USB 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).
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 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);
// 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_im = vmulq_f32(vaddq_f32(vmulq_f32(x_re, h_im), vmulq_f32(x_im, h_re)), scale_v);
let out_re = vmulq_f32(
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]
let out = vzipq_f32(out_re, out_im);
@@ -313,7 +324,8 @@ impl BlockFirFilterPair {
/// `-cutoff_norm` for LSB/CWR.
pub fn new(cutoff_norm: f32, shift_norm: f32, taps: usize, block_size: usize) -> Self {
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 {
h_freq: h_buf,
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 vchan_impl;
use dsp::IqSource as _;
use std::pin::Pin;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use dsp::IqSource as _;
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError;
use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
@@ -92,8 +92,6 @@ impl SoapySdrRig {
/// - `gain_mode`: `"auto"` or `"manual"`.
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
/// - `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).
/// - `frame_duration_ms`: output frame length (ms).
/// - `initial_freq`: initial dial frequency reported by `get_status`.
@@ -137,8 +135,6 @@ impl SoapySdrRig {
max_gain_db,
);
let agc_enabled = gain_mode == "auto";
let effective_gain_db = max_gain_db
.map(|max_gain| gain_db.min(max_gain))
.unwrap_or(gain_db);
@@ -155,7 +151,7 @@ impl SoapySdrRig {
let hardware_center_hz = initial_freq.hz as i64 - center_offset_hz;
// 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,
hardware_center_hz as f64,
sdr_sample_rate as f64,
@@ -169,6 +165,23 @@ impl SoapySdrRig {
if let Some(lna) = initial_lna_gain_db {
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 primary_channel_count = channels.len();
@@ -257,10 +270,7 @@ impl SoapySdrRig {
};
// Initialise filter state from primary channel config (index 0), or defaults.
let bandwidth_hz = channels
.first()
.map(|&(_, _, bw)| bw)
.unwrap_or(3000);
let bandwidth_hz = channels.first().map(|&(_, _, bw)| bw).unwrap_or(3000);
let spectrum_buf = pipeline.spectrum_buf.clone();
let retune_cmd = pipeline.retune_cmd.clone();
@@ -359,10 +369,7 @@ impl SoapySdrRig {
let dsps = self.pipeline.channel_dsps.read().unwrap();
for idx in [ais_a_idx, ais_b_idx] {
if let Some(dsp_arc) = dsps.get(idx) {
dsp_arc
.lock()
.unwrap()
.set_filter(self.bandwidth_hz);
dsp_arc.lock().unwrap().set_filter(self.bandwidth_hz);
}
}
}
@@ -749,10 +756,7 @@ impl RigCat for SoapySdrRig {
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
dsp_arc
.lock()
.unwrap()
.set_filter(bandwidth_hz);
dsp_arc.lock().unwrap().set_filter(bandwidth_hz);
}
}
self.apply_ais_channel_filters();
@@ -191,6 +191,12 @@ impl IqSource for RealIqSource {
.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> {
self.device
.set_gain_mode(soapysdr::Direction::Rx, 0, automatic)
@@ -100,11 +100,7 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4).
pub fn new(
pipeline: Arc<SdrPipeline>,
fixed_slot_count: usize,
max_total: usize,
) -> Self {
pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> Self {
// Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline
@@ -177,8 +173,8 @@ impl SdrVirtualChannelManager {
}
let bandwidth_hz = default_bandwidth_hz(mode);
let (pcm_tx, iq_tx) =
self.pipeline
let (pcm_tx, iq_tx) = self
.pipeline
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
let pipeline_slot = self