Signed-off-by: Stan Grams <sjg@haxx.space>
@@ -0,0 +1,32 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-client"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
flate2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
dirs = "6"
|
||||
bytes = "1"
|
||||
uuid = { workspace = true }
|
||||
cpal = "0.15"
|
||||
opus = "0.3"
|
||||
trx-app = { path = "../trx-app" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-protocol = { path = "../trx-protocol" }
|
||||
trx-frontend = { path = "trx-frontend" }
|
||||
trx-frontend-http = { path = "trx-frontend/trx-frontend-http" }
|
||||
trx-frontend-http-json = { path = "trx-frontend/trx-frontend-http-json" }
|
||||
trx-frontend-rigctl = { path = "trx-frontend/trx-frontend-rigctl" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -0,0 +1,318 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Local audio bridge for trx-client.
|
||||
//!
|
||||
//! Bridges remote Opus RX audio to a local output device and captures local
|
||||
//! input device audio for upstream TX Opus frames.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc as std_mpsc, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::AudioBridgeConfig;
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
|
||||
const BRIDGE_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
pub fn spawn_audio_bridge(
|
||||
cfg: AudioBridgeConfig,
|
||||
rx_audio_tx: broadcast::Sender<Bytes>,
|
||||
tx_audio_tx: mpsc::Sender<Bytes>,
|
||||
mut stream_info_rx: watch::Receiver<Option<AudioStreamInfo>>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
while !*shutdown_rx.borrow() {
|
||||
let info = match wait_for_stream_info(&mut stream_info_rx, &mut shutdown_rx).await {
|
||||
Some(info) => info,
|
||||
None => return,
|
||||
};
|
||||
|
||||
info!(
|
||||
"Audio bridge: starting with stream {}Hz {}ch {}ms",
|
||||
info.sample_rate, info.channels, info.frame_duration_ms
|
||||
);
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let playback_stop = stop.clone();
|
||||
let capture_stop = stop.clone();
|
||||
|
||||
let mut rx_packets = rx_audio_tx.subscribe();
|
||||
let (rx_bridge_tx, rx_bridge_rx) = std_mpsc::sync_channel::<Bytes>(128);
|
||||
let rx_forward_stop = stop.clone();
|
||||
let rx_forward = tokio::spawn(async move {
|
||||
while !rx_forward_stop.load(Ordering::Relaxed) {
|
||||
match rx_packets.recv().await {
|
||||
Ok(pkt) => {
|
||||
let _ = rx_bridge_tx.try_send(pkt);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let playback_cfg = cfg.clone();
|
||||
let playback_info = info.clone();
|
||||
let playback = std::thread::spawn(move || {
|
||||
if let Err(e) =
|
||||
run_playback(playback_cfg, playback_info, rx_bridge_rx, playback_stop)
|
||||
{
|
||||
warn!("Audio bridge playback stopped: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let capture_cfg = cfg.clone();
|
||||
let capture_info = info.clone();
|
||||
let tx_audio_tx_clone = tx_audio_tx.clone();
|
||||
let capture = std::thread::spawn(move || {
|
||||
if let Err(e) =
|
||||
run_capture(capture_cfg, capture_info, tx_audio_tx_clone, capture_stop)
|
||||
{
|
||||
warn!("Audio bridge capture stopped: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {}
|
||||
changed = stream_info_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
rx_forward.abort();
|
||||
let _ = playback.join();
|
||||
let _ = capture.join();
|
||||
|
||||
if *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(BRIDGE_RETRY_DELAY).await;
|
||||
}
|
||||
info!("Audio bridge stopped");
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_stream_info(
|
||||
stream_info_rx: &mut watch::Receiver<Option<AudioStreamInfo>>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
) -> Option<AudioStreamInfo> {
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
return None;
|
||||
}
|
||||
if let Some(info) = stream_info_rx.borrow().clone() {
|
||||
return Some(info);
|
||||
}
|
||||
tokio::select! {
|
||||
changed = stream_info_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_playback(
|
||||
cfg: AudioBridgeConfig,
|
||||
info: AudioStreamInfo,
|
||||
rx_packets: std_mpsc::Receiver<Bytes>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let host = cpal::default_host();
|
||||
let device = select_output_device(&host, cfg.rx_output_device.as_deref())?;
|
||||
let stream_cfg = cpal::StreamConfig {
|
||||
channels: info.channels as u16,
|
||||
sample_rate: cpal::SampleRate(info.sample_rate),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
let channels = stream_cfg.channels as usize;
|
||||
let frame_samples =
|
||||
(info.sample_rate as usize * info.frame_duration_ms as usize / 1000) * channels;
|
||||
|
||||
let opus_channels = match stream_cfg.channels {
|
||||
1 => opus::Channels::Mono,
|
||||
2 => opus::Channels::Stereo,
|
||||
_ => return Err(format!("unsupported channel count {}", stream_cfg.channels).into()),
|
||||
};
|
||||
let mut decoder = opus::Decoder::new(info.sample_rate, opus_channels)?;
|
||||
let mut pcm_buf = vec![0f32; 5760 * channels];
|
||||
|
||||
let ring = Arc::new(Mutex::new(VecDeque::<f32>::with_capacity(
|
||||
frame_samples * 8,
|
||||
)));
|
||||
let ring_cb = ring.clone();
|
||||
let rx_gain = cfg.rx_gain.max(0.0);
|
||||
|
||||
let err_stop = stop.clone();
|
||||
let stream = device.build_output_stream(
|
||||
&stream_cfg,
|
||||
move |data: &mut [f32], _| {
|
||||
let mut rb = ring_cb.lock().expect("audio playback ring mutex poisoned");
|
||||
for sample in data.iter_mut() {
|
||||
let v = rb.pop_front().unwrap_or(0.0) * rx_gain;
|
||||
*sample = v.clamp(-1.0, 1.0);
|
||||
}
|
||||
},
|
||||
move |err| {
|
||||
warn!("Audio bridge playback stream error: {}", err);
|
||||
err_stop.store(true, Ordering::Relaxed);
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
|
||||
stream.play()?;
|
||||
info!(
|
||||
"Audio bridge playback active on '{}'",
|
||||
device.name().unwrap_or_else(|_| "unknown".to_string())
|
||||
);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match rx_packets.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(packet) => match decoder.decode_float(&packet, &mut pcm_buf, false) {
|
||||
Ok(decoded_samples_per_channel) => {
|
||||
let decoded_total = decoded_samples_per_channel * channels;
|
||||
let mut rb = ring.lock().expect("audio playback ring mutex poisoned");
|
||||
rb.extend(pcm_buf[..decoded_total].iter().copied());
|
||||
let max_len = frame_samples * 16;
|
||||
if rb.len() > max_len {
|
||||
let drain = rb.len() - max_len;
|
||||
rb.drain(..drain);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Audio bridge Opus RX decode error: {}", e),
|
||||
},
|
||||
Err(std_mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stream.pause();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_capture(
|
||||
cfg: AudioBridgeConfig,
|
||||
info: AudioStreamInfo,
|
||||
tx_audio_tx: mpsc::Sender<Bytes>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let host = cpal::default_host();
|
||||
let device = select_input_device(&host, cfg.tx_input_device.as_deref())?;
|
||||
let stream_cfg = cpal::StreamConfig {
|
||||
channels: info.channels as u16,
|
||||
sample_rate: cpal::SampleRate(info.sample_rate),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
let channels = stream_cfg.channels as usize;
|
||||
let frame_samples =
|
||||
(info.sample_rate as usize * info.frame_duration_ms as usize / 1000) * channels;
|
||||
|
||||
let opus_channels = match stream_cfg.channels {
|
||||
1 => opus::Channels::Mono,
|
||||
2 => opus::Channels::Stereo,
|
||||
_ => return Err(format!("unsupported channel count {}", stream_cfg.channels).into()),
|
||||
};
|
||||
let mut encoder =
|
||||
opus::Encoder::new(info.sample_rate, opus_channels, opus::Application::Audio)?;
|
||||
encoder.set_bitrate(opus::Bitrate::Bits(cfg.bitrate_bps as i32))?;
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
|
||||
let (sample_tx, sample_rx) = std_mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let err_stop = stop.clone();
|
||||
let stream = device.build_input_stream(
|
||||
&stream_cfg,
|
||||
move |data: &[f32], _| {
|
||||
let _ = sample_tx.try_send(data.to_vec());
|
||||
},
|
||||
move |err| {
|
||||
warn!("Audio bridge capture stream error: {}", err);
|
||||
err_stop.store(true, Ordering::Relaxed);
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
|
||||
stream.play()?;
|
||||
info!(
|
||||
"Audio bridge capture active on '{}' ({} bps)",
|
||||
device.name().unwrap_or_else(|_| "unknown".to_string()),
|
||||
cfg.bitrate_bps
|
||||
);
|
||||
|
||||
let tx_gain = cfg.tx_gain.max(0.0);
|
||||
let mut pcm = Vec::<f32>::with_capacity(frame_samples * 2);
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
match sample_rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(samples) => {
|
||||
pcm.extend(samples.into_iter().map(|s| (s * tx_gain).clamp(-1.0, 1.0)));
|
||||
while pcm.len() >= frame_samples {
|
||||
let frame: Vec<f32> = pcm.drain(..frame_samples).collect();
|
||||
match encoder.encode_float(&frame, &mut opus_buf) {
|
||||
Ok(len) => {
|
||||
let pkt = Bytes::copy_from_slice(&opus_buf[..len]);
|
||||
let _ = tx_audio_tx.try_send(pkt);
|
||||
}
|
||||
Err(e) => warn!("Audio bridge Opus TX encode error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std_mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = stream.pause();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_output_device(
|
||||
host: &cpal::Host,
|
||||
preferred_name: Option<&str>,
|
||||
) -> Result<cpal::Device, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(name) = preferred_name {
|
||||
if let Some(device) = host
|
||||
.output_devices()?
|
||||
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||
{
|
||||
return Ok(device);
|
||||
}
|
||||
return Err(format!("output device '{}' not found", name).into());
|
||||
}
|
||||
host.default_output_device()
|
||||
.ok_or_else(|| "no default output device".into())
|
||||
}
|
||||
|
||||
fn select_input_device(
|
||||
host: &cpal::Host,
|
||||
preferred_name: Option<&str>,
|
||||
) -> Result<cpal::Device, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(name) = preferred_name {
|
||||
if let Some(device) = host
|
||||
.input_devices()?
|
||||
.find(|d| d.name().map(|n| n == name).unwrap_or(false))
|
||||
{
|
||||
return Ok(device);
|
||||
}
|
||||
return Err(format!("input device '{}' not found", name).into());
|
||||
}
|
||||
host.default_input_device()
|
||||
.ok_or_else(|| "no default input device".into())
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Audio TCP client that connects to the server's audio port and relays
|
||||
//! RX/TX Opus frames via broadcast/mpsc channels.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use flate2::read::GzDecoder;
|
||||
use std::io::Read as _;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::time;
|
||||
use tracing::{info, warn};
|
||||
use trx_frontend::RemoteRigEntry;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::remote_client::RemoteEndpoint;
|
||||
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_LRPT_IMAGE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, 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_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
||||
AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_frontend::VChanAudioCmd;
|
||||
|
||||
/// Minimum uptime before a connection is "stable" enough to reset the
|
||||
/// reconnect backoff. Connections that die before this threshold leave the
|
||||
/// exponential backoff climbing — protects the server from a tight reconnect
|
||||
/// storm when the peer is broken in some way that only manifests after the
|
||||
/// TCP handshake.
|
||||
const STABLE_CONNECTION_THRESHOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ActiveVChanSub {
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
bandwidth_hz: u32,
|
||||
hidden: bool,
|
||||
decoder_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AudioConnectConfig {
|
||||
pub server_host: String,
|
||||
pub default_port: u16,
|
||||
pub fixed_addr: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioConnectConfig {
|
||||
pub fn from_host_port(server_host: String, default_port: u16) -> Self {
|
||||
Self {
|
||||
server_host,
|
||||
default_port,
|
||||
fixed_addr: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fixed(addr: String) -> Self {
|
||||
Self {
|
||||
server_host: String::new(),
|
||||
default_port: 0,
|
||||
fixed_addr: Some(addr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-rig audio task state, tracked by the multi-rig manager.
|
||||
struct PerRigAudioTask {
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
addr: String,
|
||||
}
|
||||
|
||||
/// Multi-rig audio manager: spawns/tears down per-rig audio client tasks on
|
||||
/// demand as rigs appear/disappear from the known_rigs list. Each rig with
|
||||
/// an `audio_port` gets its own TCP connection.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_multi_rig_audio_manager(
|
||||
default_connect: AudioConnectConfig,
|
||||
rig_connect: HashMap<String, AudioConnectConfig>,
|
||||
selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||
known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||
global_rx_tx: broadcast::Sender<Bytes>,
|
||||
tx_rx: mpsc::Receiver<Bytes>,
|
||||
global_stream_info_tx: watch::Sender<Option<AudioStreamInfo>>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
replay_history_sink: Option<Arc<dyn Fn(DecodedMessage) + Send + Sync>>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||
_vchan_cmd_rx: mpsc::Receiver<VChanAudioCmd>,
|
||||
vchan_destroyed_tx: Option<broadcast::Sender<Uuid>>,
|
||||
rig_audio_rx: Arc<RwLock<HashMap<String, broadcast::Sender<Bytes>>>>,
|
||||
rig_audio_info: Arc<RwLock<HashMap<String, watch::Sender<Option<AudioStreamInfo>>>>>,
|
||||
rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
|
||||
) {
|
||||
// TX frames from the microphone go to the selected rig only.
|
||||
// We wrap the single tx_rx receiver so the per-rig task for the selected
|
||||
// rig can consume it.
|
||||
let tx_rx = Arc::new(tokio::sync::Mutex::new(tx_rx));
|
||||
|
||||
let mut active_tasks: HashMap<String, PerRigAudioTask> = HashMap::new();
|
||||
let mut poll_interval = time::interval(Duration::from_millis(500));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = poll_interval.tick() => {
|
||||
// Collect current known rigs and their audio endpoints.
|
||||
let current_rigs: HashMap<String, String> = known_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| {
|
||||
entries.iter().map(|e| {
|
||||
let addr = resolve_audio_addr(
|
||||
&e.rig_id,
|
||||
e.audio_port,
|
||||
&rig_connect,
|
||||
&default_connect,
|
||||
);
|
||||
(e.rig_id.clone(), addr)
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Tear down tasks for rigs that are no longer present or
|
||||
// whose audio endpoint has changed.
|
||||
let to_remove: Vec<String> = active_tasks.keys()
|
||||
.filter(|id| {
|
||||
match current_rigs.get(*id) {
|
||||
None => true,
|
||||
Some(addr) => active_tasks.get(*id)
|
||||
.is_none_or(|t| t.addr != *addr),
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for rig_id in &to_remove {
|
||||
if let Some(task) = active_tasks.remove(rig_id) {
|
||||
let _ = task.shutdown_tx.send(true);
|
||||
task.handle.abort();
|
||||
info!("Audio client: stopped task for rig {}", rig_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn tasks for new rigs.
|
||||
for (rig_id, addr) in ¤t_rigs {
|
||||
if active_tasks.contains_key(rig_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (per_rig_shutdown_tx, per_rig_shutdown_rx) = watch::channel(false);
|
||||
|
||||
// Ensure per-rig broadcast and info channels exist.
|
||||
let per_rig_rx_tx = {
|
||||
let mut map = rig_audio_rx.write().unwrap();
|
||||
map.entry(rig_id.clone())
|
||||
.or_insert_with(|| broadcast::channel::<Bytes>(256).0)
|
||||
.clone()
|
||||
};
|
||||
let per_rig_info_tx = {
|
||||
let mut map = rig_audio_info.write().unwrap();
|
||||
map.entry(rig_id.clone())
|
||||
.or_insert_with(|| watch::channel(None).0)
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Create per-rig vchan cmd channel (bounded to prevent
|
||||
// unbounded memory growth under backpressure).
|
||||
let (per_rig_vchan_tx, per_rig_vchan_rx) =
|
||||
mpsc::channel::<VChanAudioCmd>(256);
|
||||
if let Ok(mut map) = rig_vchan_audio_cmd.write() {
|
||||
map.insert(rig_id.clone(), per_rig_vchan_tx);
|
||||
}
|
||||
|
||||
let rig_id_clone = rig_id.clone();
|
||||
let global_rx_tx_clone = global_rx_tx.clone();
|
||||
let global_info_tx_clone = global_stream_info_tx.clone();
|
||||
let selected_clone = selected_rig_id.clone();
|
||||
let decode_tx_clone = decode_tx.clone();
|
||||
let replay_sink = replay_history_sink.clone();
|
||||
let vchan_audio_clone = vchan_audio.clone();
|
||||
let vchan_destroyed_clone = vchan_destroyed_tx.clone();
|
||||
let tx_rx_clone = tx_rx.clone();
|
||||
let addr = addr.clone();
|
||||
let task_addr = addr.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
run_single_rig_audio_client(
|
||||
task_addr,
|
||||
rig_id_clone,
|
||||
selected_clone,
|
||||
per_rig_rx_tx,
|
||||
per_rig_info_tx,
|
||||
global_rx_tx_clone,
|
||||
global_info_tx_clone,
|
||||
tx_rx_clone,
|
||||
decode_tx_clone,
|
||||
replay_sink,
|
||||
per_rig_shutdown_rx,
|
||||
vchan_audio_clone,
|
||||
per_rig_vchan_rx,
|
||||
vchan_destroyed_clone,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
info!("Audio client: started task for rig {} ({})", rig_id, addr);
|
||||
active_tasks.insert(rig_id.clone(), PerRigAudioTask {
|
||||
handle,
|
||||
shutdown_tx: per_rig_shutdown_tx,
|
||||
addr: addr.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
changed = shutdown_rx.changed() => {
|
||||
if matches!(changed, Ok(()) | Err(_)) && *shutdown_rx.borrow() {
|
||||
// Shut down all per-rig tasks.
|
||||
for (rig_id, task) in active_tasks.drain() {
|
||||
let _ = task.shutdown_tx.send(true);
|
||||
task.handle.abort();
|
||||
info!("Audio client: shutdown task for rig {}", rig_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_audio_addr(
|
||||
rig_id: &str,
|
||||
advertised_port: Option<u16>,
|
||||
rig_connect: &HashMap<String, AudioConnectConfig>,
|
||||
default_connect: &AudioConnectConfig,
|
||||
) -> String {
|
||||
let connect = rig_connect.get(rig_id).unwrap_or(default_connect);
|
||||
if let Some(addr) = &connect.fixed_addr {
|
||||
return addr.clone();
|
||||
}
|
||||
|
||||
RemoteEndpoint {
|
||||
host: connect.server_host.clone(),
|
||||
port: advertised_port.unwrap_or(connect.default_port),
|
||||
}
|
||||
.connect_addr()
|
||||
}
|
||||
|
||||
/// Audio client for a single rig. Maintains its own TCP connection with
|
||||
/// auto-reconnect, publishes RX frames to both per-rig and (if selected)
|
||||
/// global broadcast channels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_single_rig_audio_client(
|
||||
server_addr: String,
|
||||
rig_id: String,
|
||||
selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||
per_rig_rx_tx: broadcast::Sender<Bytes>,
|
||||
per_rig_info_tx: watch::Sender<Option<AudioStreamInfo>>,
|
||||
global_rx_tx: broadcast::Sender<Bytes>,
|
||||
global_info_tx: watch::Sender<Option<AudioStreamInfo>>,
|
||||
tx_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<Bytes>>>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
replay_history_sink: Option<Arc<dyn Fn(DecodedMessage) + Send + Sync>>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||
mut vchan_cmd_rx: mpsc::Receiver<VChanAudioCmd>,
|
||||
vchan_destroyed_tx: Option<broadcast::Sender<Uuid>>,
|
||||
) {
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
let mut active_subs: HashMap<Uuid, ActiveVChanSub> = HashMap::new();
|
||||
|
||||
let is_selected = |sel: &Arc<Mutex<Option<String>>>, rid: &str| -> bool {
|
||||
sel.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
.is_some_and(|s| s == rid)
|
||||
};
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("Audio client [{}]: shutting down", rig_id);
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Audio client [{}]: connecting to {}", rig_id, server_addr);
|
||||
match TcpStream::connect(&server_addr).await {
|
||||
Ok(stream) => {
|
||||
let connected_at = Instant::now();
|
||||
if let Err(e) = handle_single_rig_connection(
|
||||
stream,
|
||||
&rig_id,
|
||||
&selected_rig_id,
|
||||
&per_rig_rx_tx,
|
||||
&per_rig_info_tx,
|
||||
&global_rx_tx,
|
||||
&global_info_tx,
|
||||
&tx_rx,
|
||||
&decode_tx,
|
||||
replay_history_sink.clone(),
|
||||
&mut shutdown_rx,
|
||||
&vchan_audio,
|
||||
&mut vchan_cmd_rx,
|
||||
&mut active_subs,
|
||||
&vchan_destroyed_tx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Audio connection [{}] dropped: {}", rig_id, e);
|
||||
}
|
||||
// Only reset the backoff after a connection survived long
|
||||
// enough to be considered stable. TCP `connect()` succeeding
|
||||
// is not enough — a peer that fails immediately after
|
||||
// accepting must not be hammered every second.
|
||||
if connected_at.elapsed() >= STABLE_CONNECTION_THRESHOLD {
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Audio connect [{}] failed: {}", rig_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Do NOT clear per_rig_info_tx here — the last-known stream info
|
||||
// remains valid for this rig and clearing it would stall WebSocket
|
||||
// clients that subscribe while the TCP connection is reconnecting.
|
||||
|
||||
tokio::select! {
|
||||
_ = time::sleep(reconnect_delay) => {}
|
||||
changed = shutdown_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) if *shutdown_rx.borrow() => {
|
||||
info!("Audio client [{}]: shutting down", rig_id);
|
||||
let _ = per_rig_info_tx.send_replace(None);
|
||||
if is_selected(&selected_rig_id, &rig_id) {
|
||||
let _ = global_info_tx.send(None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(()) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_audio_addr, AudioConnectConfig};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn resolve_audio_addr_prefers_fixed_url() {
|
||||
let mut rig_connect = HashMap::new();
|
||||
rig_connect.insert(
|
||||
"home-hf".to_string(),
|
||||
AudioConnectConfig::fixed("audio.example.com:4700".to_string()),
|
||||
);
|
||||
|
||||
let addr = resolve_audio_addr(
|
||||
"home-hf",
|
||||
Some(4531),
|
||||
&rig_connect,
|
||||
&AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||
);
|
||||
assert_eq!(addr, "audio.example.com:4700");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_audio_addr_uses_advertised_port_with_remote_host() {
|
||||
let mut rig_connect = HashMap::new();
|
||||
rig_connect.insert(
|
||||
"home-hf".to_string(),
|
||||
AudioConnectConfig::from_host_port("control.example.com".to_string(), 4531),
|
||||
);
|
||||
|
||||
let addr = resolve_audio_addr(
|
||||
"home-hf",
|
||||
Some(4600),
|
||||
&rig_connect,
|
||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||
);
|
||||
assert_eq!(addr, "control.example.com:4600");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_audio_addr_falls_back_to_default_port() {
|
||||
let rig_connect = HashMap::new();
|
||||
|
||||
let addr = resolve_audio_addr(
|
||||
"home-hf",
|
||||
None,
|
||||
&rig_connect,
|
||||
&AudioConnectConfig::from_host_port("fallback.example.com".to_string(), 4531),
|
||||
);
|
||||
assert_eq!(addr, "fallback.example.com:4531");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single TCP connection for one rig. Similar to `handle_audio_connection`
|
||||
/// but publishes to per-rig channels directly and mirrors to global when selected.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_single_rig_connection(
|
||||
stream: TcpStream,
|
||||
rig_id: &str,
|
||||
selected_rig_id: &Arc<Mutex<Option<String>>>,
|
||||
per_rig_rx_tx: &broadcast::Sender<Bytes>,
|
||||
per_rig_info_tx: &watch::Sender<Option<AudioStreamInfo>>,
|
||||
global_rx_tx: &broadcast::Sender<Bytes>,
|
||||
global_info_tx: &watch::Sender<Option<AudioStreamInfo>>,
|
||||
tx_rx: &Arc<tokio::sync::Mutex<mpsc::Receiver<Bytes>>>,
|
||||
decode_tx: &broadcast::Sender<DecodedMessage>,
|
||||
replay_history_sink: Option<Arc<dyn Fn(DecodedMessage) + Send + Sync>>,
|
||||
shutdown_rx: &mut watch::Receiver<bool>,
|
||||
vchan_audio: &Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||
vchan_cmd_rx: &mut mpsc::Receiver<VChanAudioCmd>,
|
||||
active_subs: &mut HashMap<Uuid, ActiveVChanSub>,
|
||||
vchan_destroyed_tx: &Option<broadcast::Sender<Uuid>>,
|
||||
) -> std::io::Result<()> {
|
||||
let (reader, writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut writer = tokio::io::BufWriter::new(writer);
|
||||
|
||||
// Read StreamInfo
|
||||
let (msg_type, payload) = read_audio_msg(&mut reader).await?;
|
||||
if msg_type != AUDIO_MSG_STREAM_INFO {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"expected StreamInfo as first message",
|
||||
));
|
||||
}
|
||||
let info: AudioStreamInfo = serde_json::from_slice(&payload)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
info!(
|
||||
"Audio stream info [{}]: {}Hz, {} ch, {}ms",
|
||||
rig_id, info.sample_rate, info.channels, info.frame_duration_ms
|
||||
);
|
||||
let _ = per_rig_info_tx.send_replace(Some(info.clone()));
|
||||
|
||||
// Mirror to global if this is the selected rig.
|
||||
let is_selected = selected_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
.is_some_and(|s| s == rig_id);
|
||||
if is_selected {
|
||||
let _ = global_info_tx.send(Some(info));
|
||||
}
|
||||
|
||||
// Re-subscribe active virtual channels on reconnect.
|
||||
let mut resubscribed: HashSet<Uuid> = HashSet::new();
|
||||
for (&uuid, sub) in active_subs.iter() {
|
||||
let json = serde_json::json!({
|
||||
"uuid": uuid.to_string(),
|
||||
"freq_hz": sub.freq_hz,
|
||||
"mode": sub.mode,
|
||||
"hidden": sub.hidden,
|
||||
"decoder_kinds": sub.decoder_kinds,
|
||||
"bandwidth_hz": sub.bandwidth_hz,
|
||||
});
|
||||
if let Ok(payload) = serde_json::to_vec(&json) {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_SUB, &payload).await {
|
||||
warn!("Audio vchan reconnect SUB write failed [{}]: {}", rig_id, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
if sub.bandwidth_hz > 0 {
|
||||
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 [{}]: {}", rig_id, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
resubscribed.insert(uuid);
|
||||
}
|
||||
|
||||
// Spawn RX read task — publishes to per-rig and (when selected) global.
|
||||
let per_rig_rx_clone = per_rig_rx_tx.clone();
|
||||
let global_rx_clone = global_rx_tx.clone();
|
||||
let selected_for_rx = selected_rig_id.clone();
|
||||
let rig_id_for_rx = rig_id.to_string();
|
||||
let decode_tx_clone = decode_tx.clone();
|
||||
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 {
|
||||
match read_audio_msg(&mut reader).await {
|
||||
Ok((AUDIO_MSG_RX_FRAME, payload)) => {
|
||||
let data = Bytes::from(payload);
|
||||
// Always publish to per-rig channel.
|
||||
let _ = per_rig_rx_clone.send(data.clone());
|
||||
// Mirror to global if this rig is currently selected.
|
||||
let sel = selected_for_rx
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
.is_some_and(|s| s == rig_id_for_rx);
|
||||
if sel {
|
||||
let _ = global_rx_clone.send(data);
|
||||
}
|
||||
}
|
||||
Ok((AUDIO_MSG_RX_FRAME_CH, payload)) => {
|
||||
if let Ok((uuid, opus)) = parse_vchan_audio_frame(&payload) {
|
||||
let pkt = Bytes::copy_from_slice(opus);
|
||||
if let Ok(map) = vchan_audio_rx.read() {
|
||||
if let Some(tx) = map.get(&uuid) {
|
||||
let _ = tx.send(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((AUDIO_MSG_VCHAN_ALLOCATED, payload)) => {
|
||||
if let Ok(uuid) = parse_vchan_uuid_msg(&payload) {
|
||||
if let Ok(mut map) = vchan_audio_rx.write() {
|
||||
map.entry(uuid)
|
||||
.or_insert_with(|| broadcast::channel::<Bytes>(64).0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((AUDIO_MSG_VCHAN_DESTROYED, payload)) => {
|
||||
if let Ok(uuid) = parse_vchan_uuid_msg(&payload) {
|
||||
if let Ok(mut map) = vchan_audio_rx.write() {
|
||||
map.remove(&uuid);
|
||||
}
|
||||
if let Some(ref tx) = vchan_destroyed_for_rx {
|
||||
let _ = tx.send(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((AUDIO_MSG_HISTORY_COMPRESSED, payload)) => {
|
||||
let mut decompressed = Vec::new();
|
||||
if GzDecoder::new(payload.as_slice())
|
||||
.read_to_end(&mut decompressed)
|
||||
.is_ok()
|
||||
{
|
||||
let mut pos = 0;
|
||||
while pos + 5 <= decompressed.len() {
|
||||
let _msg_type = decompressed[pos];
|
||||
let len = u32::from_be_bytes([
|
||||
decompressed[pos + 1],
|
||||
decompressed[pos + 2],
|
||||
decompressed[pos + 3],
|
||||
decompressed[pos + 4],
|
||||
]) as usize;
|
||||
pos += 5;
|
||||
if pos + len > decompressed.len() {
|
||||
break;
|
||||
}
|
||||
let json = &decompressed[pos..pos + len];
|
||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(json) {
|
||||
msg.set_rig_id(rig_id_for_rx.clone());
|
||||
if let Some(ref sink) = replay_history_sink {
|
||||
sink(msg);
|
||||
}
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
AUDIO_MSG_VDES_DECODE
|
||||
| AUDIO_MSG_AIS_DECODE
|
||||
| AUDIO_MSG_APRS_DECODE
|
||||
| AUDIO_MSG_HF_APRS_DECODE
|
||||
| AUDIO_MSG_CW_DECODE
|
||||
| AUDIO_MSG_FT8_DECODE
|
||||
| AUDIO_MSG_FT4_DECODE
|
||||
| AUDIO_MSG_FT2_DECODE
|
||||
| AUDIO_MSG_WSPR_DECODE
|
||||
| AUDIO_MSG_LRPT_IMAGE
|
||||
| AUDIO_MSG_LRPT_PROGRESS
|
||||
| AUDIO_MSG_WEFAX_DECODE
|
||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
||||
payload,
|
||||
)) => {
|
||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||
msg.set_rig_id(rig_id_for_rx.clone());
|
||||
let _ = decode_tx_clone.send(msg);
|
||||
}
|
||||
}
|
||||
Ok((msg_type, _)) => {
|
||||
warn!(
|
||||
"Audio client [{}]: unexpected message type {:#04x}",
|
||||
rig_id_for_rx, msg_type
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Audio client [{}]: read error: {}", rig_id_for_rx, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Forward TX frames (only when we are the selected rig) and vchan commands.
|
||||
let rig_id_owned = rig_id.to_string();
|
||||
loop {
|
||||
// Only the selected rig should consume TX frames from the mic.
|
||||
let is_sel = selected_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
.is_some_and(|s| s == rig_id_owned);
|
||||
|
||||
tokio::select! {
|
||||
changed = shutdown_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) if *shutdown_rx.borrow() => {
|
||||
rx_handle.abort();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(()) => {}
|
||||
Err(_) => {
|
||||
rx_handle.abort();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
packet = async {
|
||||
if is_sel {
|
||||
tx_rx.lock().await.recv().await
|
||||
} else {
|
||||
// Not selected — don't consume TX frames; pend forever.
|
||||
std::future::pending().await
|
||||
}
|
||||
} => {
|
||||
match packet {
|
||||
Some(data) => {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_TX_FRAME, &data).await {
|
||||
warn!("Audio TX write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
cmd = vchan_cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Some(VChanAudioCmd::Subscribe { uuid, freq_hz, mode, bandwidth_hz, decoder_kinds }) => {
|
||||
active_subs.insert(uuid, ActiveVChanSub {
|
||||
freq_hz,
|
||||
mode: mode.clone(),
|
||||
bandwidth_hz,
|
||||
hidden: false,
|
||||
decoder_kinds: decoder_kinds.clone(),
|
||||
});
|
||||
if resubscribed.remove(&uuid) {
|
||||
} else {
|
||||
let json = serde_json::json!({
|
||||
"uuid": uuid.to_string(),
|
||||
"freq_hz": freq_hz,
|
||||
"mode": mode,
|
||||
"hidden": false,
|
||||
"decoder_kinds": decoder_kinds,
|
||||
"bandwidth_hz": bandwidth_hz,
|
||||
});
|
||||
if let Ok(payload) = serde_json::to_vec(&json) {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_SUB, &payload).await {
|
||||
warn!("Audio vchan SUB write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(VChanAudioCmd::SubscribeBackground { uuid, freq_hz, mode, bandwidth_hz, decoder_kinds }) => {
|
||||
active_subs.insert(uuid, ActiveVChanSub {
|
||||
freq_hz,
|
||||
mode: mode.clone(),
|
||||
bandwidth_hz,
|
||||
hidden: true,
|
||||
decoder_kinds: decoder_kinds.clone(),
|
||||
});
|
||||
if resubscribed.remove(&uuid) {
|
||||
} else {
|
||||
let json = serde_json::json!({
|
||||
"uuid": uuid.to_string(),
|
||||
"freq_hz": freq_hz,
|
||||
"mode": mode,
|
||||
"hidden": true,
|
||||
"decoder_kinds": decoder_kinds,
|
||||
"bandwidth_hz": bandwidth_hz,
|
||||
});
|
||||
if let Ok(payload) = serde_json::to_vec(&json) {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_SUB, &payload).await {
|
||||
warn!("Audio background SUB write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(VChanAudioCmd::Unsubscribe(uuid)) => {
|
||||
if let Err(e) = write_vchan_uuid_msg(&mut writer, AUDIO_MSG_VCHAN_UNSUB, uuid).await {
|
||||
warn!("Audio vchan UNSUB write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(VChanAudioCmd::Remove(uuid)) => {
|
||||
if let Err(e) = write_vchan_uuid_msg(&mut writer, AUDIO_MSG_VCHAN_REMOVE, uuid).await {
|
||||
warn!("Audio vchan REMOVE write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
if let Ok(mut map) = vchan_audio.write() {
|
||||
map.remove(&uuid);
|
||||
}
|
||||
active_subs.remove(&uuid);
|
||||
}
|
||||
Some(VChanAudioCmd::SetFreq { uuid, freq_hz }) => {
|
||||
if let Some(entry) = active_subs.get_mut(&uuid) {
|
||||
entry.freq_hz = freq_hz;
|
||||
}
|
||||
let json = serde_json::json!({ "uuid": uuid.to_string(), "freq_hz": freq_hz });
|
||||
if let Ok(payload) = serde_json::to_vec(&json) {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_FREQ, &payload).await {
|
||||
warn!("Audio vchan FREQ write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(VChanAudioCmd::SetMode { uuid, mode }) => {
|
||||
if let Some(entry) = active_subs.get_mut(&uuid) {
|
||||
entry.mode = mode.clone();
|
||||
}
|
||||
let json = serde_json::json!({ "uuid": uuid.to_string(), "mode": mode });
|
||||
if let Ok(payload) = serde_json::to_vec(&json) {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_MODE, &payload).await {
|
||||
warn!("Audio vchan MODE write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(VChanAudioCmd::SetBandwidth { uuid, bandwidth_hz }) => {
|
||||
if let Some(entry) = active_subs.get_mut(&uuid) {
|
||||
entry.bandwidth_hz = bandwidth_hz;
|
||||
}
|
||||
let json = serde_json::json!({ "uuid": uuid.to_string(), "bandwidth_hz": bandwidth_hz });
|
||||
if let Ok(payload) = serde_json::to_vec(&json) {
|
||||
if let Err(e) = write_audio_msg(&mut writer, AUDIO_MSG_VCHAN_BW, &payload).await {
|
||||
warn!("Audio vchan BW write failed [{}]: {}", rig_id_owned, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ = &mut rx_handle => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
mod audio_bridge;
|
||||
mod audio_client;
|
||||
mod config;
|
||||
mod remote_client;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use clap::Parser;
|
||||
use tokio::signal;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use trx_app::{init_logging, normalize_name};
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
use trx_core::DynResult;
|
||||
use trx_frontend::{FrontendRegistrationContext, FrontendRuntimeContext};
|
||||
use trx_frontend_http::register_frontend_on as register_http_frontend;
|
||||
use trx_frontend_http_json::register_frontend_on as register_http_json_frontend;
|
||||
use trx_frontend_rigctl::register_frontend_on as register_rigctl_frontend;
|
||||
|
||||
use audio_client::AudioConnectConfig;
|
||||
use config::{ClientConfig, RemoteEntry};
|
||||
use remote_client::{parse_audio_url, parse_remote_url, RemoteClientConfig};
|
||||
|
||||
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - remote rig client");
|
||||
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author = env!("CARGO_PKG_AUTHORS"),
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
about = PKG_DESCRIPTION,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to configuration file
|
||||
#[arg(long = "config", short = 'C', value_name = "FILE")]
|
||||
config: Option<PathBuf>,
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
print_config: bool,
|
||||
/// Remote server URL (host:port)
|
||||
#[arg(short = 'u', long = "url")]
|
||||
url: Option<String>,
|
||||
/// Authentication token for the remote server
|
||||
#[arg(long = "token")]
|
||||
token: Option<String>,
|
||||
/// Poll interval in milliseconds
|
||||
#[arg(long = "poll-interval")]
|
||||
poll_interval_ms: Option<u64>,
|
||||
/// Target rig ID on a multi-rig remote server
|
||||
#[arg(long = "rig-id")]
|
||||
rig_id: Option<String>,
|
||||
/// Frontend(s) to expose locally (e.g. http,rigctl)
|
||||
#[arg(short = 'f', long = "frontend", value_delimiter = ',', num_args = 1..)]
|
||||
frontends: Option<Vec<String>>,
|
||||
/// HTTP frontend listen address
|
||||
#[arg(long = "http-listen")]
|
||||
http_listen: Option<IpAddr>,
|
||||
/// HTTP frontend listen port
|
||||
#[arg(long = "http-port")]
|
||||
http_port: Option<u16>,
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// rigctl frontend listen port
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
#[arg(long = "http-json-listen")]
|
||||
http_json_listen: Option<IpAddr>,
|
||||
/// JSON TCP frontend listen port
|
||||
#[arg(long = "http-json-port")]
|
||||
http_json_port: Option<u16>,
|
||||
/// Optional callsign/owner label to show in the frontend
|
||||
#[arg(short = 'c', long = "callsign")]
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> DynResult<()> {
|
||||
let app_state = async_init().await?;
|
||||
signal::ctrl_c().await?;
|
||||
info!("Ctrl+C received, shutting down");
|
||||
|
||||
let _ = app_state.shutdown_tx.send(true);
|
||||
drop(app_state.request_tx);
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
for handle in &app_state.task_handles {
|
||||
if !handle.is_finished() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
for handle in app_state.task_handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Holds the state needed after async initialization completes.
|
||||
struct AppState {
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
task_handles: Vec<JoinHandle<()>>,
|
||||
request_tx: mpsc::Sender<RigRequest>,
|
||||
}
|
||||
|
||||
async fn async_init() -> DynResult<AppState> {
|
||||
use std::sync::Arc;
|
||||
|
||||
// Phase 3: Create bootstrap context for explicit initialization.
|
||||
// This replaces reliance on global mutable state by threading context through spawn_frontend.
|
||||
let mut frontend_reg_ctx = FrontendRegistrationContext::new();
|
||||
let mut frontend_runtime = FrontendRuntimeContext::new();
|
||||
|
||||
register_http_frontend(&mut frontend_reg_ctx);
|
||||
register_http_json_frontend(&mut frontend_reg_ctx);
|
||||
register_rigctl_frontend(&mut frontend_reg_ctx);
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.print_config {
|
||||
println!("{}", ClientConfig::example_combined_toml());
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ClientConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
} else {
|
||||
ClientConfig::load_from_default_paths()?
|
||||
};
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
|
||||
init_logging(cfg.general.log_level.as_deref());
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
|
||||
frontend_runtime.http_auth.tokens = cfg
|
||||
.frontends
|
||||
.http_json
|
||||
.auth
|
||||
.tokens
|
||||
.iter()
|
||||
.filter(|t| !t.is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Set HTTP frontend authentication config
|
||||
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
|
||||
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
|
||||
frontend_runtime.http_auth.control_passphrase =
|
||||
cfg.frontends.http.auth.control_passphrase.clone();
|
||||
frontend_runtime.http_auth.tx_access_control_enabled =
|
||||
cfg.frontends.http.auth.tx_access_control_enabled;
|
||||
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
|
||||
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
|
||||
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
|
||||
config::CookieSameSite::Strict => "Strict".to_string(),
|
||||
config::CookieSameSite::Lax => "Lax".to_string(),
|
||||
config::CookieSameSite::None => "None".to_string(),
|
||||
};
|
||||
frontend_runtime.http_ui.show_sdr_gain_control = cfg.frontends.http.show_sdr_gain_control;
|
||||
frontend_runtime.http_ui.initial_map_zoom = cfg.frontends.http.initial_map_zoom;
|
||||
frontend_runtime.http_ui.spectrum_coverage_margin_hz =
|
||||
cfg.frontends.http.spectrum_coverage_margin_hz;
|
||||
frontend_runtime.http_ui.spectrum_usable_span_ratio =
|
||||
cfg.frontends.http.spectrum_usable_span_ratio;
|
||||
frontend_runtime.http_ui.bandplan_enabled = cfg.frontends.http.bandplan_enabled;
|
||||
frontend_runtime.http_ui.bandplan_region = cfg.frontends.http.bandplan_region.clone();
|
||||
frontend_runtime.http_ui.decode_history_retention_min =
|
||||
cfg.frontends.http.decode_history_retention_min;
|
||||
frontend_runtime.http_ui.decode_history_retention_min_by_rig = cfg
|
||||
.frontends
|
||||
.http
|
||||
.decode_history_retention_min_by_rig
|
||||
.clone();
|
||||
|
||||
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
||||
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
||||
// CLI --url creates a single implicit remote entry
|
||||
let rig_id = cli.rig_id.clone().or_else(|| cfg.remote.rig_id.clone());
|
||||
let name = rig_id.clone().unwrap_or_else(|| "default".to_string());
|
||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
||||
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||
vec![RemoteEntry {
|
||||
name,
|
||||
url: url.clone(),
|
||||
rig_id,
|
||||
auth: config::RemoteAuthConfig { token },
|
||||
poll_interval_ms,
|
||||
}]
|
||||
} else {
|
||||
let entries = cfg.resolved_remotes();
|
||||
if entries.is_empty() {
|
||||
return Err(
|
||||
"No remote servers configured. Use --url or add [[remotes]] entries in config."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
entries
|
||||
};
|
||||
|
||||
// Set initial active rig to the configured default or first remote entry.
|
||||
let default_rig = cli
|
||||
.rig_id
|
||||
.clone()
|
||||
.or_else(|| cfg.frontends.http.default_rig_name.clone())
|
||||
.or_else(|| resolved_remotes.first().map(|e| e.name.clone()));
|
||||
if let Ok(mut guard) = frontend_runtime.routing.active_rig_id.lock() {
|
||||
*guard = default_rig.clone();
|
||||
}
|
||||
|
||||
// Resolve frontends: CLI > config > default to http
|
||||
let frontends: Vec<String> = if let Some(ref fes) = cli.frontends {
|
||||
fes.iter().map(|f| normalize_name(f)).collect()
|
||||
} else {
|
||||
let mut fes = Vec::new();
|
||||
if cfg.frontends.http.enabled {
|
||||
fes.push("http".to_string());
|
||||
}
|
||||
if cfg.frontends.rigctl.enabled {
|
||||
fes.push("rigctl".to_string());
|
||||
}
|
||||
if cfg.frontends.http_json.enabled {
|
||||
fes.push("httpjson".to_string());
|
||||
}
|
||||
if fes.is_empty() {
|
||||
fes.push("http".to_string());
|
||||
}
|
||||
fes
|
||||
};
|
||||
for name in &frontends {
|
||||
if !frontend_reg_ctx.is_frontend_registered(name) {
|
||||
return Err(format!(
|
||||
"Unknown frontend: {} (available: {})",
|
||||
name,
|
||||
frontend_reg_ctx.registered_frontends().join(", ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let http_listen = cli.http_listen.unwrap_or(cfg.frontends.http.listen);
|
||||
let http_port = cli.http_port.unwrap_or(cfg.frontends.http.port);
|
||||
let rigctl_listen = cli.rigctl_listen.unwrap_or(cfg.frontends.rigctl.listen);
|
||||
let http_json_listen = cli
|
||||
.http_json_listen
|
||||
.unwrap_or(cfg.frontends.http_json.listen);
|
||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||
let callsign = cli
|
||||
.callsign
|
||||
.clone()
|
||||
.or_else(|| cfg.general.callsign.clone());
|
||||
frontend_runtime.owner.callsign = callsign.clone();
|
||||
frontend_runtime.owner.website_url = cfg.general.website_url.clone();
|
||||
frontend_runtime.owner.website_name = cfg.general.website_name.clone();
|
||||
frontend_runtime.owner.ais_vessel_url_base = cfg.general.ais_vessel_url_base.clone();
|
||||
|
||||
let remote_names: Vec<&str> = resolved_remotes.iter().map(|e| e.name.as_str()).collect();
|
||||
info!(
|
||||
"Starting trx-client (remotes: [{}], frontends: {})",
|
||||
remote_names.join(", "),
|
||||
frontends.join(", ")
|
||||
);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let mut task_handles: Vec<JoinHandle<()>> = Vec::new();
|
||||
|
||||
let initial_state = RigState::new_uninitialized();
|
||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||
|
||||
// Group remote entries by (addr, token) so entries sharing a server share
|
||||
// one TCP connection. Each group gets its own run_remote_client task.
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
// Parse all endpoints upfront.
|
||||
let parsed_remotes: Vec<(RemoteEntry, remote_client::RemoteEndpoint)> = resolved_remotes
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let ep = parse_remote_url(&entry.url)
|
||||
.map_err(|e| format!("Invalid URL for remote '{}': {}", entry.name, e))?;
|
||||
Ok((entry.clone(), ep))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
|
||||
let global_audio_addr = cfg
|
||||
.frontends
|
||||
.audio
|
||||
.server_url
|
||||
.as_deref()
|
||||
.map(|url| {
|
||||
parse_audio_url(url)
|
||||
.map(|endpoint| endpoint.connect_addr())
|
||||
.map_err(|e| format!("Invalid audio URL override '{}': {}", url, e))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Build per-short-name audio connection defaults.
|
||||
let mut audio_connect: HashMap<String, AudioConnectConfig> = HashMap::new();
|
||||
for (entry, ep) in &parsed_remotes {
|
||||
let connect = if let Some(url) = cfg.frontends.audio.rig_urls.get(&entry.name) {
|
||||
let addr = parse_audio_url(url)
|
||||
.map(|endpoint| endpoint.connect_addr())
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Invalid audio URL override for remote '{}': {}",
|
||||
entry.name, e
|
||||
)
|
||||
})?;
|
||||
AudioConnectConfig::fixed(addr)
|
||||
} else if let Some(addr) = global_audio_addr.clone() {
|
||||
AudioConnectConfig::fixed(addr)
|
||||
} else {
|
||||
let audio_port = cfg
|
||||
.frontends
|
||||
.audio
|
||||
.rig_ports
|
||||
.get(&entry.name)
|
||||
.copied()
|
||||
.unwrap_or(cfg.frontends.audio.server_port);
|
||||
AudioConnectConfig::from_host_port(ep.host.clone(), audio_port)
|
||||
};
|
||||
audio_connect.insert(entry.name.clone(), connect);
|
||||
}
|
||||
|
||||
// Group by (connect_addr, token).
|
||||
let mut server_groups: BTreeMap<(String, Option<String>), Vec<&RemoteEntry>> = BTreeMap::new();
|
||||
let mut endpoint_by_addr: HashMap<String, remote_client::RemoteEndpoint> = HashMap::new();
|
||||
for (entry, ep) in &parsed_remotes {
|
||||
let key = (ep.connect_addr(), entry.auth.token.clone());
|
||||
endpoint_by_addr
|
||||
.entry(ep.connect_addr())
|
||||
.or_insert_with(|| ep.clone());
|
||||
server_groups.entry(key).or_default().push(entry);
|
||||
}
|
||||
|
||||
// Per-server request senders for the routing dispatcher.
|
||||
let mut route_map: HashMap<String, mpsc::Sender<RigRequest>> = HashMap::new();
|
||||
|
||||
for ((addr, token), entries) in &server_groups {
|
||||
// Build the rig_id → short_name mapping for this server group.
|
||||
let mut rig_id_to_short_name: HashMap<Option<String>, String> = HashMap::new();
|
||||
for entry in entries {
|
||||
rig_id_to_short_name.insert(entry.rig_id.clone(), entry.name.clone());
|
||||
}
|
||||
|
||||
let poll_interval = entries
|
||||
.iter()
|
||||
.map(|e| e.poll_interval_ms)
|
||||
.min()
|
||||
.unwrap_or(750);
|
||||
|
||||
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
for entry in entries {
|
||||
route_map.insert(entry.name.clone(), server_tx.clone());
|
||||
}
|
||||
|
||||
let remote_cfg = RemoteClientConfig {
|
||||
addr: addr.clone(),
|
||||
token: token.clone(),
|
||||
selected_rig_id: frontend_runtime.routing.active_rig_id.clone(),
|
||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||
poll_interval: Duration::from_millis(poll_interval),
|
||||
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||
rig_server_connected: frontend_runtime.routing.rig_server_connected.clone(),
|
||||
rig_id_to_short_name,
|
||||
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
||||
sat_passes: frontend_runtime.routing.sat_passes.clone(),
|
||||
rig_meters: frontend_runtime.routing.rig_meters.clone(),
|
||||
};
|
||||
let state_tx = state_tx.clone();
|
||||
let remote_shutdown_rx = shutdown_rx.clone();
|
||||
task_handles.push(tokio::spawn(async move {
|
||||
if let Err(e) = remote_client::run_remote_client(
|
||||
remote_cfg,
|
||||
server_rx,
|
||||
state_tx,
|
||||
remote_shutdown_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Remote client error: {}", e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Request routing dispatcher: receives from the single frontend-facing
|
||||
// channel and dispatches to the per-server channel based on rig_id_override
|
||||
// (short name).
|
||||
let route_map = Arc::new(route_map);
|
||||
let default_rig_for_router = frontend_runtime.routing.active_rig_id.clone();
|
||||
{
|
||||
let route_map = route_map.clone();
|
||||
let mut frontend_rx = rx;
|
||||
task_handles.push(tokio::spawn(async move {
|
||||
while let Some(req) = frontend_rx.recv().await {
|
||||
let target = req
|
||||
.rig_id_override
|
||||
.as_deref()
|
||||
.map(String::from)
|
||||
.or_else(|| default_rig_for_router.lock().ok().and_then(|g| g.clone()));
|
||||
let sender = target
|
||||
.as_deref()
|
||||
.and_then(|name| route_map.get(name))
|
||||
.or_else(|| route_map.values().next());
|
||||
if let Some(sender) = sender {
|
||||
let _ = sender.send(req).await;
|
||||
} else {
|
||||
let _ = req.respond_to.send(Err(trx_core::RigError::communication(
|
||||
"no remote server available for this rig",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract first remote host for audio backward-compat fallback.
|
||||
let remote_host = parsed_remotes
|
||||
.first()
|
||||
.map(|(_, ep)| ep.host.clone())
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
|
||||
// Audio streaming setup
|
||||
let mut pending_audio_client = None;
|
||||
let mut pending_audio_bridge = None;
|
||||
if cfg.frontends.audio.enabled {
|
||||
let (rx_audio_tx, _) = broadcast::channel::<Bytes>(256);
|
||||
let (tx_audio_tx, tx_audio_rx) = mpsc::channel::<Bytes>(64);
|
||||
let (stream_info_tx, stream_info_rx) = watch::channel::<Option<AudioStreamInfo>>(None);
|
||||
let (decode_tx, _) = broadcast::channel::<DecodedMessage>(256);
|
||||
|
||||
frontend_runtime.audio.rx = Some(rx_audio_tx.clone());
|
||||
frontend_runtime.audio.tx = Some(tx_audio_tx);
|
||||
frontend_runtime.audio.info = Some(stream_info_rx);
|
||||
frontend_runtime.audio.decode_rx = Some(decode_tx.clone());
|
||||
|
||||
// Virtual-channel audio: shared broadcaster map + command channel.
|
||||
let (vchan_cmd_tx, vchan_cmd_rx) = mpsc::channel::<trx_frontend::VChanAudioCmd>(256);
|
||||
*frontend_runtime.vchan.audio_cmd.lock().unwrap() = Some(vchan_cmd_tx);
|
||||
|
||||
let (vchan_destroyed_tx, _) = broadcast::channel::<uuid::Uuid>(64);
|
||||
frontend_runtime.vchan.destroyed = Some(vchan_destroyed_tx.clone());
|
||||
let ais_history = frontend_runtime.decode_history.ais.clone();
|
||||
let vdes_history = frontend_runtime.decode_history.vdes.clone();
|
||||
let aprs_history = frontend_runtime.decode_history.aprs.clone();
|
||||
let hf_aprs_history = frontend_runtime.decode_history.hf_aprs.clone();
|
||||
let cw_history = frontend_runtime.decode_history.cw.clone();
|
||||
let ft8_history = frontend_runtime.decode_history.ft8.clone();
|
||||
let wspr_history = frontend_runtime.decode_history.wspr.clone();
|
||||
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) => {
|
||||
if message.ts_ms.is_none() {
|
||||
message.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
if let Ok(mut history) = ais_history.lock() {
|
||||
history.push_back((now, None, message));
|
||||
}
|
||||
}
|
||||
DecodedMessage::Vdes(mut message) => {
|
||||
if message.ts_ms.is_none() {
|
||||
message.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
if let Ok(mut history) = vdes_history.lock() {
|
||||
history.push_back((now, None, message));
|
||||
}
|
||||
}
|
||||
DecodedMessage::Aprs(mut packet) => {
|
||||
if packet.ts_ms.is_none() {
|
||||
packet.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
if let Ok(mut history) = aprs_history.lock() {
|
||||
history.push_back((now, None, packet));
|
||||
}
|
||||
}
|
||||
DecodedMessage::HfAprs(mut packet) => {
|
||||
if packet.ts_ms.is_none() {
|
||||
packet.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
if let Ok(mut history) = hf_aprs_history.lock() {
|
||||
history.push_back((now, None, packet));
|
||||
}
|
||||
}
|
||||
DecodedMessage::Cw(event) => {
|
||||
if let Ok(mut history) = cw_history.lock() {
|
||||
history.push_back((now, None, event));
|
||||
}
|
||||
}
|
||||
DecodedMessage::Ft8(message) => {
|
||||
if let Ok(mut history) = ft8_history.lock() {
|
||||
history.push_back((now, None, message));
|
||||
}
|
||||
}
|
||||
DecodedMessage::Ft4(_) => {
|
||||
// FT4 history is managed by the frontend HTTP audio collector
|
||||
}
|
||||
DecodedMessage::Ft2(_) => {
|
||||
// FT2 history is managed by the frontend HTTP audio collector
|
||||
}
|
||||
DecodedMessage::Wspr(message) => {
|
||||
if let Ok(mut history) = wspr_history.lock() {
|
||||
history.push_back((now, None, message));
|
||||
}
|
||||
}
|
||||
DecodedMessage::LrptImage(_) => {}
|
||||
DecodedMessage::LrptProgress(_) => {}
|
||||
DecodedMessage::Wefax(_) => {}
|
||||
DecodedMessage::WefaxProgress(_) => {}
|
||||
}
|
||||
});
|
||||
|
||||
info!("Audio enabled: decode channel set");
|
||||
|
||||
let audio_shutdown_rx = shutdown_rx.clone();
|
||||
let vchan_audio_map = frontend_runtime.vchan.audio.clone();
|
||||
let rig_audio_rx_map = frontend_runtime.rig_audio.rx.clone();
|
||||
let rig_audio_info_map = frontend_runtime.rig_audio.info.clone();
|
||||
let rig_vchan_cmd_map = frontend_runtime.vchan.rig_audio_cmd.clone();
|
||||
let default_audio_connect = if let Some(addr) = global_audio_addr {
|
||||
AudioConnectConfig::fixed(addr)
|
||||
} else {
|
||||
AudioConnectConfig::from_host_port(remote_host.clone(), cfg.frontends.audio.server_port)
|
||||
};
|
||||
pending_audio_client = Some(tokio::spawn(audio_client::run_multi_rig_audio_manager(
|
||||
default_audio_connect,
|
||||
audio_connect,
|
||||
frontend_runtime.routing.active_rig_id.clone(),
|
||||
frontend_runtime.routing.remote_rigs.clone(),
|
||||
rx_audio_tx,
|
||||
tx_audio_rx,
|
||||
stream_info_tx,
|
||||
decode_tx,
|
||||
Some(replay_history_sink),
|
||||
audio_shutdown_rx,
|
||||
vchan_audio_map,
|
||||
vchan_cmd_rx,
|
||||
Some(vchan_destroyed_tx),
|
||||
rig_audio_rx_map,
|
||||
rig_audio_info_map,
|
||||
rig_vchan_cmd_map,
|
||||
)));
|
||||
|
||||
if cfg.frontends.audio.bridge.enabled {
|
||||
pending_audio_bridge = Some(cfg.frontends.audio.bridge.clone());
|
||||
}
|
||||
} else {
|
||||
info!("Audio disabled in config, decode will not be available");
|
||||
}
|
||||
|
||||
let frontend_runtime_ctx = Arc::new(frontend_runtime);
|
||||
|
||||
// Start decode history collector before audio client starts replay.
|
||||
// Frontend tasks are spawned asynchronously, so starting the collector
|
||||
// here avoids missing the initial server-side history burst.
|
||||
if cfg.frontends.audio.enabled {
|
||||
trx_frontend_http::server::audio::start_decode_history_collector(
|
||||
frontend_runtime_ctx.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Spawn frontends with runtime context
|
||||
for frontend in &frontends {
|
||||
let frontend_state_rx = state_rx.clone();
|
||||
|
||||
// rigctl: always spawn one listener per configured rig entry.
|
||||
if frontend == "rigctl" {
|
||||
let mut first = true;
|
||||
for (rig_id, &port) in &cfg.frontends.rigctl.rig_ports {
|
||||
let addr = SocketAddr::from((rigctl_listen, port));
|
||||
if first {
|
||||
if let Ok(mut listen_addr) = frontend_runtime_ctx.rigctl_listen_addr.lock() {
|
||||
*listen_addr = Some(addr);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
// Proxy channel: inject rig_id_override before forwarding to main tx.
|
||||
let (proxy_tx, mut proxy_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
let main_tx = tx.clone();
|
||||
let rig_id_owned = rig_id.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(req) = proxy_rx.recv().await {
|
||||
let forwarded = RigRequest {
|
||||
cmd: req.cmd,
|
||||
respond_to: req.respond_to,
|
||||
rig_id_override: Some(rig_id_owned.clone()),
|
||||
};
|
||||
let _ = main_tx.send(forwarded).await;
|
||||
}
|
||||
});
|
||||
info!("rigctl frontend for rig '{}' on {}", rig_id, addr);
|
||||
frontend_reg_ctx.spawn_frontend(
|
||||
frontend,
|
||||
state_rx.clone(),
|
||||
proxy_tx,
|
||||
callsign.clone(),
|
||||
addr,
|
||||
frontend_runtime_ctx.clone(),
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let addr = match frontend.as_str() {
|
||||
"http" => SocketAddr::from((http_listen, http_port)),
|
||||
"httpjson" => SocketAddr::from((http_json_listen, http_json_port)),
|
||||
other => {
|
||||
return Err(format!("Frontend missing listen configuration: {}", other).into());
|
||||
}
|
||||
};
|
||||
frontend_reg_ctx.spawn_frontend(
|
||||
frontend,
|
||||
frontend_state_rx,
|
||||
tx.clone(),
|
||||
callsign.clone(),
|
||||
addr,
|
||||
frontend_runtime_ctx.clone(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Start the audio connection only after frontends are running so decode
|
||||
// subscribers can capture the server's initial history replay.
|
||||
if let Some(handle) = pending_audio_client {
|
||||
task_handles.push(handle);
|
||||
}
|
||||
if let Some(bridge_cfg) = pending_audio_bridge {
|
||||
info!("Audio bridge enabled (local virtual-device integration)");
|
||||
task_handles.push(audio_bridge::spawn_audio_bridge(
|
||||
bridge_cfg,
|
||||
frontend_runtime_ctx
|
||||
.audio
|
||||
.rx
|
||||
.as_ref()
|
||||
.expect("audio rx must be set")
|
||||
.clone(),
|
||||
frontend_runtime_ctx
|
||||
.audio
|
||||
.tx
|
||||
.as_ref()
|
||||
.expect("audio tx must be set")
|
||||
.clone(),
|
||||
frontend_runtime_ctx
|
||||
.audio
|
||||
.info
|
||||
.as_ref()
|
||||
.expect("audio info must be set")
|
||||
.clone(),
|
||||
shutdown_rx.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(AppState {
|
||||
shutdown_tx,
|
||||
task_handles,
|
||||
request_tx: tx,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_timestamp_ms() -> i64 {
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
i64::try_from(millis).unwrap_or(i64::MAX)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-frontend"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bytes = "1"
|
||||
uuid = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
trx-core = { path = "../../trx-core" }
|
||||
trx-protocol = { path = "../../trx-protocol" }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
@@ -0,0 +1,546 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
};
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
use trx_protocol::MeterUpdate;
|
||||
|
||||
/// Shared, timestamped decode history for a single decoder type.
|
||||
///
|
||||
/// Each entry is `(record_time, optional_rig_id, decoded_message)`.
|
||||
pub type DecodeHistory<T> = Arc<Mutex<VecDeque<(Instant, Option<String>, T)>>>;
|
||||
|
||||
/// Command sent by the HTTP frontend to the audio-client task to manage a
|
||||
/// virtual channel's audio stream over the server's audio TCP connection.
|
||||
#[derive(Debug)]
|
||||
pub enum VChanAudioCmd {
|
||||
/// Create the server-side DSP channel (if it does not exist) and subscribe
|
||||
/// to its Opus audio stream. `freq_hz` and `mode` are used if the server
|
||||
/// needs to create the channel.
|
||||
Subscribe {
|
||||
uuid: Uuid,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
bandwidth_hz: u32,
|
||||
decoder_kinds: Vec<String>,
|
||||
},
|
||||
/// Create a hidden server-side DSP channel for background decoding.
|
||||
/// These channels are not enumerated as user-visible virtual channels and
|
||||
/// do not request an Opus audio stream back to the frontend.
|
||||
SubscribeBackground {
|
||||
uuid: Uuid,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
bandwidth_hz: u32,
|
||||
decoder_kinds: Vec<String>,
|
||||
},
|
||||
/// Unsubscribe from audio (encoder task is stopped) but keep the DSP channel.
|
||||
Unsubscribe(Uuid),
|
||||
/// Unsubscribe and destroy the DSP channel.
|
||||
Remove(Uuid),
|
||||
/// Update the dial frequency of an existing virtual channel.
|
||||
SetFreq { uuid: Uuid, freq_hz: u64 },
|
||||
/// Update the demodulation mode of an existing virtual channel.
|
||||
SetMode { uuid: Uuid, mode: String },
|
||||
/// Update the audio filter bandwidth of an existing virtual channel.
|
||||
SetBandwidth { uuid: Uuid, bandwidth_hz: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteRigEntry {
|
||||
pub rig_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub state: RigSnapshot,
|
||||
pub audio_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Trait implemented by concrete frontends to expose a runner entrypoint.
|
||||
pub trait FrontendSpawner {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> JoinHandle<()>;
|
||||
}
|
||||
|
||||
/// Spectrum snapshot shared between the spectrum polling task and SSE clients.
|
||||
///
|
||||
/// Stored in a `watch::channel`; each SSE client subscribes and is woken
|
||||
/// exactly when new data arrives (no 40 ms polling loop needed on the reader
|
||||
/// side). `Arc<SpectrumData>` makes clone O(1) regardless of bin count.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SharedSpectrum {
|
||||
/// Latest spectrum frame; `None` when the active backend has no spectrum.
|
||||
pub frame: Option<Arc<SpectrumData>>,
|
||||
/// RDS JSON pre-serialised at ingestion so SSE clients don't repeat the
|
||||
/// work on every tick.
|
||||
pub rds_json: Option<String>,
|
||||
/// Virtual-channel RDS JSON pre-serialised at ingestion.
|
||||
pub vchan_rds_json: Option<String>,
|
||||
}
|
||||
|
||||
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>>,
|
||||
) {
|
||||
self.rds_json = frame
|
||||
.as_ref()
|
||||
.and_then(|f| f.rds.as_ref())
|
||||
.and_then(|r| serde_json::to_string(r).ok());
|
||||
self.vchan_rds_json = vchan_rds
|
||||
.as_ref()
|
||||
.and_then(|list| serde_json::to_string(list).ok());
|
||||
self.frame = frame.map(Arc::new);
|
||||
}
|
||||
}
|
||||
|
||||
pub type FrontendSpawnFn = fn(
|
||||
watch::Receiver<RigState>,
|
||||
mpsc::Sender<RigRequest>,
|
||||
Option<String>,
|
||||
SocketAddr,
|
||||
Arc<FrontendRuntimeContext>,
|
||||
) -> JoinHandle<()>;
|
||||
|
||||
/// Context for registering and spawning frontends.
|
||||
#[derive(Clone)]
|
||||
pub struct FrontendRegistrationContext {
|
||||
spawners: HashMap<String, FrontendSpawnFn>,
|
||||
}
|
||||
|
||||
impl FrontendRegistrationContext {
|
||||
/// Create a new empty registration context.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
spawners: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a frontend spawner under a stable name (e.g. "http").
|
||||
pub fn register_frontend(&mut self, name: &str, spawner: FrontendSpawnFn) {
|
||||
let key = normalize_name(name);
|
||||
self.spawners.insert(key, spawner);
|
||||
}
|
||||
|
||||
/// Check whether a frontend name is registered.
|
||||
pub fn is_frontend_registered(&self, name: &str) -> bool {
|
||||
let key = normalize_name(name);
|
||||
self.spawners.contains_key(&key)
|
||||
}
|
||||
|
||||
/// List registered frontend names.
|
||||
pub fn registered_frontends(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self.spawners.keys().cloned().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Spawn a registered frontend by name with runtime context.
|
||||
pub fn spawn_frontend(
|
||||
&self,
|
||||
name: &str,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> DynResult<JoinHandle<()>> {
|
||||
let key = normalize_name(name);
|
||||
let spawner = self
|
||||
.spawners
|
||||
.get(&key)
|
||||
.ok_or_else(|| format!("Unknown frontend: {}", name))?;
|
||||
Ok(spawner(state_rx, rig_tx, callsign, listen_addr, context))
|
||||
}
|
||||
|
||||
/// Merge another registration context into this one.
|
||||
pub fn extend_from(&mut self, other: &FrontendRegistrationContext) {
|
||||
for (name, spawner) in &other.spawners {
|
||||
self.spawners.insert(name.clone(), *spawner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrontendRegistrationContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-structs for FrontendRuntimeContext decomposition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Audio streaming channels (server ↔ browser).
|
||||
pub struct AudioContext {
|
||||
/// Audio RX broadcast channel (server → browser)
|
||||
pub rx: Option<broadcast::Sender<Bytes>>,
|
||||
/// Audio TX channel (browser → server)
|
||||
pub tx: Option<mpsc::Sender<Bytes>>,
|
||||
/// Audio stream info watch channel
|
||||
pub info: Option<watch::Receiver<Option<AudioStreamInfo>>>,
|
||||
/// Decode message broadcast channel
|
||||
pub decode_rx: Option<broadcast::Sender<DecodedMessage>>,
|
||||
/// Active audio WebSocket streams.
|
||||
pub clients: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Default for AudioContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rx: None,
|
||||
tx: None,
|
||||
info: None,
|
||||
decode_rx: None,
|
||||
clients: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode history entries for all decoder types.
|
||||
pub struct DecodeHistoryContext {
|
||||
pub ais: DecodeHistory<AisMessage>,
|
||||
pub vdes: DecodeHistory<VdesMessage>,
|
||||
pub aprs: DecodeHistory<AprsPacket>,
|
||||
pub hf_aprs: DecodeHistory<AprsPacket>,
|
||||
pub cw: DecodeHistory<CwEvent>,
|
||||
pub ft8: DecodeHistory<Ft8Message>,
|
||||
pub ft4: DecodeHistory<Ft8Message>,
|
||||
pub ft2: DecodeHistory<Ft8Message>,
|
||||
pub wspr: DecodeHistory<WsprMessage>,
|
||||
pub wefax: DecodeHistory<WefaxMessage>,
|
||||
}
|
||||
|
||||
impl Default for DecodeHistoryContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ais: Arc::new(Mutex::new(VecDeque::new())),
|
||||
vdes: Arc::new(Mutex::new(VecDeque::new())),
|
||||
aprs: Arc::new(Mutex::new(VecDeque::new())),
|
||||
hf_aprs: Arc::new(Mutex::new(VecDeque::new())),
|
||||
cw: Arc::new(Mutex::new(VecDeque::new())),
|
||||
ft8: Arc::new(Mutex::new(VecDeque::new())),
|
||||
ft4: Arc::new(Mutex::new(VecDeque::new())),
|
||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP authentication configuration.
|
||||
pub struct HttpAuthConfig {
|
||||
pub enabled: bool,
|
||||
pub rx_passphrase: Option<String>,
|
||||
pub control_passphrase: Option<String>,
|
||||
pub tx_access_control_enabled: bool,
|
||||
pub session_ttl_secs: u64,
|
||||
pub cookie_secure: bool,
|
||||
pub cookie_same_site: String,
|
||||
/// Authentication tokens for HTTP-JSON frontend.
|
||||
pub tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for HttpAuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
rx_passphrase: None,
|
||||
control_passphrase: None,
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl_secs: 480 * 60,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: "Lax".to_string(),
|
||||
tokens: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP UI display configuration.
|
||||
pub struct HttpUiConfig {
|
||||
pub show_sdr_gain_control: bool,
|
||||
pub initial_map_zoom: u8,
|
||||
pub spectrum_coverage_margin_hz: u32,
|
||||
pub spectrum_usable_span_ratio: f32,
|
||||
pub bandplan_enabled: bool,
|
||||
pub bandplan_region: String,
|
||||
pub decode_history_retention_min: u64,
|
||||
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
impl Default for HttpUiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_sdr_gain_control: true,
|
||||
initial_map_zoom: 10,
|
||||
spectrum_coverage_margin_hz: 50_000,
|
||||
spectrum_usable_span_ratio: 0.92,
|
||||
bandplan_enabled: true,
|
||||
bandplan_region: "iaru_r1".to_string(),
|
||||
decode_history_retention_min: 24 * 60,
|
||||
decode_history_retention_min_by_rig: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote rig routing and state management.
|
||||
pub struct RigRoutingContext {
|
||||
/// Currently selected remote rig id.
|
||||
pub active_rig_id: Arc<Mutex<Option<String>>>,
|
||||
/// Cached remote rig list from GetRigs polling.
|
||||
pub remote_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||
/// Cached satellite pass predictions from the server (GetSatPasses).
|
||||
pub sat_passes: Arc<RwLock<Option<trx_core::geo::PassPredictionResult>>>,
|
||||
/// Per-rig state watch channels, keyed by rig_id.
|
||||
pub rig_states: Arc<RwLock<HashMap<String, watch::Sender<RigState>>>>,
|
||||
/// Whether the remote client currently has an active TCP connection.
|
||||
pub server_connected: Arc<AtomicBool>,
|
||||
/// Per-rig server connection state.
|
||||
pub rig_server_connected: Arc<RwLock<HashMap<String, bool>>>,
|
||||
/// Per-rig meter watch channels, keyed by rig_id. Populated lazily by
|
||||
/// the meter-connection supervisor in `trx-client`; `None` on the sender
|
||||
/// side means "no sample yet".
|
||||
pub rig_meters: Arc<RwLock<HashMap<String, watch::Sender<Option<MeterUpdate>>>>>,
|
||||
}
|
||||
|
||||
impl Default for RigRoutingContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_rig_id: Arc::new(Mutex::new(None)),
|
||||
remote_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
sat_passes: Arc::new(RwLock::new(None)),
|
||||
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
||||
server_connected: Arc::new(AtomicBool::new(false)),
|
||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner/station metadata for frontend display.
|
||||
#[derive(Default)]
|
||||
pub struct OwnerInfo {
|
||||
pub callsign: Option<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub website_name: Option<String>,
|
||||
pub ais_vessel_url_base: Option<String>,
|
||||
}
|
||||
|
||||
/// Virtual channel audio management.
|
||||
pub struct VChanContext {
|
||||
/// Per-virtual-channel Opus audio senders.
|
||||
pub audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||
/// Channel to send `VChanAudioCmd` to the audio-client task.
|
||||
pub audio_cmd: Arc<Mutex<Option<mpsc::Sender<VChanAudioCmd>>>>,
|
||||
/// Broadcast sender that fires when the server destroys a virtual channel.
|
||||
pub destroyed: Option<broadcast::Sender<Uuid>>,
|
||||
/// Per-rig virtual-channel command senders.
|
||||
pub rig_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
|
||||
}
|
||||
|
||||
impl Default for VChanContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
audio: Arc::new(RwLock::new(HashMap::new())),
|
||||
audio_cmd: Arc::new(Mutex::new(None)),
|
||||
destroyed: None,
|
||||
rig_audio_cmd: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spectrum data management.
|
||||
pub struct SpectrumContext {
|
||||
/// Spectrum sender; SSE clients subscribe via `sender.subscribe()`.
|
||||
pub sender: Arc<watch::Sender<SharedSpectrum>>,
|
||||
/// Per-rig spectrum watch channels, keyed by rig_id.
|
||||
pub per_rig: Arc<RwLock<HashMap<String, watch::Sender<SharedSpectrum>>>>,
|
||||
}
|
||||
|
||||
impl Default for SpectrumContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sender: {
|
||||
let (tx, _rx) = watch::channel(SharedSpectrum::default());
|
||||
Arc::new(tx)
|
||||
},
|
||||
per_rig: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-rig audio channels for multi-rig setups.
|
||||
pub struct PerRigAudioContext {
|
||||
/// Per-rig RX audio broadcast senders.
|
||||
pub rx: Arc<RwLock<HashMap<String, broadcast::Sender<Bytes>>>>,
|
||||
/// Per-rig audio stream info watch channels.
|
||||
pub info: Arc<RwLock<HashMap<String, watch::Sender<Option<AudioStreamInfo>>>>>,
|
||||
}
|
||||
|
||||
impl Default for PerRigAudioContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rx: Arc::new(RwLock::new(HashMap::new())),
|
||||
info: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime context for frontend operation.
|
||||
///
|
||||
/// Decomposed into coherent sub-structs to improve readability and allow
|
||||
/// frontends to access only the context groups they need.
|
||||
pub struct FrontendRuntimeContext {
|
||||
/// Audio streaming channels.
|
||||
pub audio: AudioContext,
|
||||
/// Decode history for all decoder types.
|
||||
pub decode_history: DecodeHistoryContext,
|
||||
/// HTTP authentication configuration.
|
||||
pub http_auth: HttpAuthConfig,
|
||||
/// HTTP UI display configuration.
|
||||
pub http_ui: HttpUiConfig,
|
||||
/// Remote rig routing and state.
|
||||
pub routing: RigRoutingContext,
|
||||
/// Owner/station metadata.
|
||||
pub owner: OwnerInfo,
|
||||
/// Virtual channel management.
|
||||
pub vchan: VChanContext,
|
||||
/// Spectrum data.
|
||||
pub spectrum: SpectrumContext,
|
||||
/// Per-rig audio channels.
|
||||
pub rig_audio: PerRigAudioContext,
|
||||
/// Active HTTP SSE clients.
|
||||
pub sse_clients: Arc<AtomicUsize>,
|
||||
/// Active rigctl TCP clients.
|
||||
pub rigctl_clients: Arc<AtomicUsize>,
|
||||
/// rigctl listen endpoint, if enabled.
|
||||
pub rigctl_listen_addr: Arc<Mutex<Option<SocketAddr>>>,
|
||||
/// Guard to avoid spawning duplicate decode collectors.
|
||||
pub decode_collector_started: AtomicBool,
|
||||
}
|
||||
|
||||
impl FrontendRuntimeContext {
|
||||
/// Get a watch receiver for a specific rig's state.
|
||||
pub fn rig_state_rx(&self, rig_id: &str) -> Option<watch::Receiver<RigState>> {
|
||||
self.routing
|
||||
.rig_states
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
||||
}
|
||||
|
||||
/// Get a watch receiver for a specific rig's meter stream.
|
||||
/// Lazily inserts a new channel if the rig_id is not yet present so
|
||||
/// SSE clients can subscribe before the meter-connection supervisor
|
||||
/// has produced a first sample.
|
||||
pub fn rig_meter_rx(&self, rig_id: &str) -> watch::Receiver<Option<MeterUpdate>> {
|
||||
if let Ok(map) = self.routing.rig_meters.read() {
|
||||
if let Some(tx) = map.get(rig_id) {
|
||||
return tx.subscribe();
|
||||
}
|
||||
}
|
||||
if let Ok(mut map) = self.routing.rig_meters.write() {
|
||||
map.entry(rig_id.to_string())
|
||||
.or_insert_with(|| watch::channel(None).0)
|
||||
.subscribe()
|
||||
} else {
|
||||
watch::channel(None).1
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a watch receiver for a specific rig's spectrum.
|
||||
/// Lazily inserts a new channel if the rig_id is not yet present.
|
||||
pub fn rig_spectrum_rx(&self, rig_id: &str) -> watch::Receiver<SharedSpectrum> {
|
||||
if let Ok(map) = self.spectrum.per_rig.read() {
|
||||
if let Some(tx) = map.get(rig_id) {
|
||||
return tx.subscribe();
|
||||
}
|
||||
}
|
||||
// Insert on miss.
|
||||
if let Ok(mut map) = self.spectrum.per_rig.write() {
|
||||
map.entry(rig_id.to_string())
|
||||
.or_insert_with(|| watch::channel(SharedSpectrum::default()).0)
|
||||
.subscribe()
|
||||
} else {
|
||||
// Poisoned lock fallback: return a dummy receiver.
|
||||
watch::channel(SharedSpectrum::default()).1
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to a specific rig's RX audio broadcast.
|
||||
pub fn rig_audio_subscribe(&self, rig_id: &str) -> Option<broadcast::Receiver<Bytes>> {
|
||||
self.rig_audio
|
||||
.rx
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
||||
}
|
||||
|
||||
/// Get a watch receiver for a specific rig's audio stream info.
|
||||
pub fn rig_audio_info_rx(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
) -> Option<watch::Receiver<Option<AudioStreamInfo>>> {
|
||||
self.rig_audio
|
||||
.info
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|map| map.get(rig_id).map(|tx| tx.subscribe()))
|
||||
}
|
||||
|
||||
/// Create a new empty runtime context.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
audio: AudioContext::default(),
|
||||
decode_history: DecodeHistoryContext::default(),
|
||||
http_auth: HttpAuthConfig::default(),
|
||||
http_ui: HttpUiConfig::default(),
|
||||
routing: RigRoutingContext::default(),
|
||||
owner: OwnerInfo::default(),
|
||||
vchan: VChanContext::default(),
|
||||
spectrum: SpectrumContext::default(),
|
||||
rig_audio: PerRigAudioContext::default(),
|
||||
sse_clients: Arc::new(AtomicUsize::new(0)),
|
||||
rigctl_clients: Arc::new(AtomicUsize::new(0)),
|
||||
rigctl_listen_addr: Arc::new(Mutex::new(None)),
|
||||
decode_collector_started: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrontendRuntimeContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-http-json"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationContext) {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
context.register_frontend("http-json", server::HttpJsonFrontend::spawn_frontend);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time;
|
||||
use tracing::{error, info};
|
||||
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
|
||||
use trx_protocol::auth::{SimpleTokenValidator, TokenValidator};
|
||||
use trx_protocol::codec::parse_envelope;
|
||||
use trx_protocol::mapping;
|
||||
use trx_protocol::types::{ClientCommand, RigEntry};
|
||||
use trx_protocol::ClientResponse;
|
||||
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// JSON-over-TCP frontend for control and status.
|
||||
pub struct HttpJsonFrontend;
|
||||
|
||||
impl FrontendSpawner for HttpJsonFrontend {
|
||||
fn spawn_frontend(
|
||||
_state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(listen_addr, rig_tx, context).await {
|
||||
error!("json tcp server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
listen_addr: SocketAddr,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> std::io::Result<()> {
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
info!("json tcp frontend listening on {}", listen_addr);
|
||||
|
||||
loop {
|
||||
let (socket, addr) = listener.accept().await?;
|
||||
info!("json tcp client connected: {}", addr);
|
||||
|
||||
let tx_clone = rig_tx.clone();
|
||||
let context = context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(socket, addr, tx_clone, context).await {
|
||||
error!("json tcp client {} error: {:?}", addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
socket: TcpStream,
|
||||
addr: SocketAddr,
|
||||
tx: mpsc::Sender<RigRequest>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> std::io::Result<()> {
|
||||
let (reader, mut writer) = socket.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
loop {
|
||||
let line = time::timeout(
|
||||
IO_TIMEOUT,
|
||||
read_limited_line(&mut reader, MAX_JSON_LINE_BYTES),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"read timeout waiting for client request",
|
||||
)
|
||||
})??;
|
||||
let Some(line) = line else {
|
||||
info!("json tcp client {} disconnected", addr);
|
||||
break;
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let envelope = match parse_envelope(trimmed) {
|
||||
Ok(envelope) => envelope,
|
||||
Err(e) => {
|
||||
error!("Invalid JSON from {}: {} / {:?}", addr, trimmed, e);
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: None,
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some(format!("Invalid JSON: {}", e)),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = authorize(&envelope.token, &context) {
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: None,
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some(err),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rig_id) = envelope.rig_id.as_ref() {
|
||||
if let Ok(mut active) = context.routing.active_rig_id.lock() {
|
||||
*active = Some(rig_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(&envelope.cmd, ClientCommand::GetRigs) {
|
||||
let resp = ClientResponse {
|
||||
success: true,
|
||||
rig_id: Some("client".to_string()),
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: Some(snapshot_remote_rigs(context.as_ref())),
|
||||
sat_passes: None,
|
||||
error: None,
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_rig_id = context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone());
|
||||
|
||||
let rig_cmd = mapping::client_command_to_rig(envelope.cmd);
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
let req = RigRequest {
|
||||
cmd: rig_cmd,
|
||||
respond_to: resp_tx,
|
||||
rig_id_override: None,
|
||||
};
|
||||
|
||||
match time::timeout(IO_TIMEOUT, tx.send(req)).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to send request to rig_task: {:?}", e);
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: active_rig_id.clone(),
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some("Internal error: rig task not available".into()),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: active_rig_id.clone(),
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some("Internal error: request queue timeout".into()),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match time::timeout(REQUEST_TIMEOUT, resp_rx).await {
|
||||
Ok(Ok(Ok(snapshot))) => {
|
||||
let resp = ClientResponse {
|
||||
success: true,
|
||||
rig_id: active_rig_id.clone(),
|
||||
protocol_version: None,
|
||||
state: Some(snapshot),
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: None,
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
}
|
||||
Ok(Ok(Err(err))) => {
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: active_rig_id.clone(),
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some(err.message),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("Rig response oneshot recv error: {:?}", e);
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: active_rig_id.clone(),
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some("Internal error waiting for rig response".into()),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
}
|
||||
Err(_) => {
|
||||
let resp = ClientResponse {
|
||||
success: false,
|
||||
rig_id: active_rig_id.clone(),
|
||||
protocol_version: None,
|
||||
state: None,
|
||||
rigs: None,
|
||||
sat_passes: None,
|
||||
error: Some("Request timed out waiting for rig response".into()),
|
||||
};
|
||||
send_response(&mut writer, &resp).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_remote_rigs(context: &FrontendRuntimeContext) -> Vec<RigEntry> {
|
||||
context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| RigEntry {
|
||||
rig_id: entry.rig_id.clone(),
|
||||
display_name: entry.display_name.clone(),
|
||||
state: entry.state.clone(),
|
||||
audio_port: entry.audio_port,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn read_limited_line<R: AsyncBufRead + Unpin>(
|
||||
reader: &mut R,
|
||||
max_bytes: usize,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
let mut line = Vec::with_capacity(256);
|
||||
loop {
|
||||
let available = reader.fill_buf().await?;
|
||||
if available.is_empty() {
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = String::from_utf8(line).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("line is not valid UTF-8: {e}"),
|
||||
)
|
||||
})?;
|
||||
return Ok(Some(text));
|
||||
}
|
||||
|
||||
if let Some(pos) = available.iter().position(|b| *b == b'\n') {
|
||||
let chunk = &available[..=pos];
|
||||
if line.len() + chunk.len() > max_bytes {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("line exceeds maximum size of {max_bytes} bytes"),
|
||||
));
|
||||
}
|
||||
line.extend_from_slice(chunk);
|
||||
reader.consume(pos + 1);
|
||||
let text = String::from_utf8(line).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("line is not valid UTF-8: {e}"),
|
||||
)
|
||||
})?;
|
||||
return Ok(Some(text));
|
||||
}
|
||||
|
||||
if line.len() + available.len() > max_bytes {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("line exceeds maximum size of {max_bytes} bytes"),
|
||||
));
|
||||
}
|
||||
|
||||
line.extend_from_slice(available);
|
||||
let consumed = available.len();
|
||||
reader.consume(consumed);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_response(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
response: &ClientResponse,
|
||||
) -> std::io::Result<()> {
|
||||
let resp_line = serde_json::to_string(response).map_err(std::io::Error::other)? + "\n";
|
||||
time::timeout(IO_TIMEOUT, writer.write_all(resp_line.as_bytes()))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "response write timeout")
|
||||
})??;
|
||||
time::timeout(IO_TIMEOUT, writer.flush())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "response flush timeout")
|
||||
})??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authorize(token: &Option<String>, context: &FrontendRuntimeContext) -> Result<(), String> {
|
||||
let validator = SimpleTokenValidator::new(context.http_auth.tokens.clone());
|
||||
validator.validate(token)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use trx_core::radio::freq::{Band, Freq};
|
||||
use trx_core::rig::state::RigSnapshot;
|
||||
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo, RigStatus, RigTxStatus};
|
||||
use trx_core::RigMode;
|
||||
|
||||
fn loopback_addr() -> SocketAddr {
|
||||
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind");
|
||||
let addr = listener.local_addr().expect("local_addr");
|
||||
drop(listener);
|
||||
addr
|
||||
}
|
||||
|
||||
fn sample_snapshot() -> RigSnapshot {
|
||||
RigSnapshot {
|
||||
info: RigInfo {
|
||||
manufacturer: "Test".to_string(),
|
||||
model: "Dummy".to_string(),
|
||||
revision: "1".to_string(),
|
||||
capabilities: RigCapabilities {
|
||||
min_freq_step_hz: 1,
|
||||
supported_bands: vec![Band {
|
||||
low_hz: 14_000_000,
|
||||
high_hz: 14_350_000,
|
||||
tx_allowed: true,
|
||||
}],
|
||||
supported_modes: vec![RigMode::USB],
|
||||
num_vfos: 1,
|
||||
lock: false,
|
||||
lockable: true,
|
||||
attenuator: false,
|
||||
preamp: false,
|
||||
rit: false,
|
||||
rpt: false,
|
||||
split: false,
|
||||
tx: true,
|
||||
tx_limit: true,
|
||||
vfo_switch: true,
|
||||
filter_controls: false,
|
||||
signal_meter: true,
|
||||
},
|
||||
access: RigAccessMethod::Tcp {
|
||||
addr: "127.0.0.1:1234".to_string(),
|
||||
},
|
||||
},
|
||||
status: RigStatus {
|
||||
freq: Freq { hz: 14_074_000 },
|
||||
mode: RigMode::USB,
|
||||
tx_en: false,
|
||||
vfo: None,
|
||||
tx: Some(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
}),
|
||||
rx: None,
|
||||
lock: Some(false),
|
||||
},
|
||||
band: None,
|
||||
enabled: Some(true),
|
||||
initialized: true,
|
||||
server_callsign: Some("N0CALL".to_string()),
|
||||
server_version: Some("test".to_string()),
|
||||
server_build_date: Some("2026-01-01".to_string()),
|
||||
server_latitude: None,
|
||||
server_longitude: None,
|
||||
pskreporter_status: Some("Disabled".to_string()),
|
||||
aprs_is_status: Some("Disabled".to_string()),
|
||||
decoders: trx_core::DecoderConfig::default(),
|
||||
cw_auto: true,
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
filter: None,
|
||||
spectrum: None,
|
||||
vchan_rds: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires TCP bind permissions"]
|
||||
async fn rejects_missing_token() {
|
||||
let addr = loopback_addr();
|
||||
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
|
||||
let mut runtime = FrontendRuntimeContext::new();
|
||||
runtime.http_auth.tokens = HashSet::from(["secret".to_string()]);
|
||||
let ctx = Arc::new(runtime);
|
||||
|
||||
let handle = tokio::spawn(serve(addr, rig_tx, ctx));
|
||||
|
||||
let stream = TcpStream::connect(addr).await.expect("connect");
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
writer
|
||||
.write_all(br#"{"cmd":"get_state"}"#)
|
||||
.await
|
||||
.expect("write");
|
||||
writer.write_all(b"\n").await.expect("newline");
|
||||
writer.flush().await.expect("flush");
|
||||
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.expect("read");
|
||||
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
|
||||
assert!(!resp.success);
|
||||
assert_eq!(resp.error.as_deref(), Some("missing authorization token"));
|
||||
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires TCP bind permissions"]
|
||||
async fn forwards_command_and_returns_snapshot() {
|
||||
let addr = loopback_addr();
|
||||
let (rig_tx, mut rig_rx) = mpsc::channel::<RigRequest>(8);
|
||||
let ctx = Arc::new(FrontendRuntimeContext::new());
|
||||
|
||||
let rig_worker = tokio::spawn(async move {
|
||||
if let Some(req) = rig_rx.recv().await {
|
||||
let _ = req.respond_to.send(Ok(sample_snapshot()));
|
||||
}
|
||||
});
|
||||
let handle = tokio::spawn(serve(addr, rig_tx, ctx));
|
||||
|
||||
let stream = TcpStream::connect(addr).await.expect("connect");
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
writer
|
||||
.write_all(br#"{"cmd":"get_state"}"#)
|
||||
.await
|
||||
.expect("write");
|
||||
writer.write_all(b"\n").await.expect("newline");
|
||||
writer.flush().await.expect("flush");
|
||||
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.expect("read");
|
||||
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
|
||||
assert!(resp.success);
|
||||
assert_eq!(resp.state.expect("snapshot").status.freq.hz, 14_074_000);
|
||||
|
||||
let _ = rig_worker.await;
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-http"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
base64 = "0.22"
|
||||
actix-web = "4.4"
|
||||
actix-ws = "0.3"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
flate2 = { workspace = true }
|
||||
brotli = "7"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
pickledb = "0.5"
|
||||
dirs = "6"
|
||||
uuid = { workspace = true }
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"iaru_r1": {
|
||||
"name": "IARU Region 1",
|
||||
"bands": [
|
||||
{
|
||||
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
|
||||
"segments": [
|
||||
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "630m", "low_hz": 472000, "high_hz": 479000,
|
||||
"segments": [
|
||||
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "160m", "low_hz": 1810000, "high_hz": 2000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1810000, "high_hz": 1838000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 1838000, "high_hz": 1840000, "mode": "Narrow", "label": "Narrow" },
|
||||
{ "low_hz": 1840000, "high_hz": 2000000, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "80m", "low_hz": 3500000, "high_hz": 3800000,
|
||||
"segments": [
|
||||
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 3600000, "high_hz": 3620000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 3620000, "high_hz": 3800000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "60m", "low_hz": 5351500, "high_hz": 5366500,
|
||||
"segments": [
|
||||
{ "low_hz": 5351500, "high_hz": 5354000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 5354000, "high_hz": 5366500, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40m", "low_hz": 7000000, "high_hz": 7200000,
|
||||
"segments": [
|
||||
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7100000, "high_hz": 7200000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
|
||||
"segments": [
|
||||
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
|
||||
"segments": [
|
||||
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
|
||||
"segments": [
|
||||
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
|
||||
"segments": [
|
||||
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
|
||||
"segments": [
|
||||
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
|
||||
"segments": [
|
||||
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 28225000, "high_hz": 28320000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 28320000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
|
||||
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
|
||||
"segments": [
|
||||
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 50100000, "high_hz": 50500000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 50500000, "high_hz": 51000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 51000000, "high_hz": 52000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 52000000, "high_hz": 54000000, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "4m", "low_hz": 70000000, "high_hz": 70500000,
|
||||
"segments": [
|
||||
{ "low_hz": 70000000, "high_hz": 70100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 70100000, "high_hz": 70250000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 70250000, "high_hz": 70300000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 70300000, "high_hz": 70500000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
|
||||
"segments": [
|
||||
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2m", "low_hz": 144000000, "high_hz": 146000000,
|
||||
"segments": [
|
||||
{ "low_hz": 144000000, "high_hz": 144150000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 144150000, "high_hz": 144400000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 144400000, "high_hz": 144490000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 144490000, "high_hz": 144500000, "mode": "Beacon", "label": "NCDXF Beacon" },
|
||||
{ "low_hz": 144500000, "high_hz": 144794000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 144794000, "high_hz": 144990000, "mode": "Narrow", "label": "Digital/APRS" },
|
||||
{ "low_hz": 144990000, "high_hz": 145194000, "mode": "FM", "label": "FM Simplex" },
|
||||
{ "low_hz": 145194000, "high_hz": 145806000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 145806000, "high_hz": 146000000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "70cm", "low_hz": 430000000, "high_hz": 440000000,
|
||||
"segments": [
|
||||
{ "low_hz": 430000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 432000000, "high_hz": 432150000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 432150000, "high_hz": 432500000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 432500000, "high_hz": 432800000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 432800000, "high_hz": 433000000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 433000000, "high_hz": 435000000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 438000000, "high_hz": 440000000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1240000000, "high_hz": 1243000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 1243000000, "high_hz": 1260000000, "mode": "Narrow", "label": "Digital/ATV" },
|
||||
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 1270000000, "high_hz": 1300000000, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"iaru_r2": {
|
||||
"name": "IARU Region 2",
|
||||
"bands": [
|
||||
{
|
||||
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
|
||||
"segments": [
|
||||
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "630m", "low_hz": 472000, "high_hz": 479000,
|
||||
"segments": [
|
||||
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "160m", "low_hz": 1800000, "high_hz": 2000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1800000, "high_hz": 1840000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 1840000, "high_hz": 1850000, "mode": "Narrow", "label": "CW/Digi" },
|
||||
{ "low_hz": 1850000, "high_hz": 2000000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "80m", "low_hz": 3500000, "high_hz": 4000000,
|
||||
"segments": [
|
||||
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 3600000, "high_hz": 3700000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 3700000, "high_hz": 4000000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "60m", "low_hz": 5330500, "high_hz": 5403500,
|
||||
"segments": [
|
||||
{ "low_hz": 5330500, "high_hz": 5403500, "mode": "All", "label": "All Modes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40m", "low_hz": 7000000, "high_hz": 7300000,
|
||||
"segments": [
|
||||
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7100000, "high_hz": 7125000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7125000, "high_hz": 7300000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
|
||||
"segments": [
|
||||
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
|
||||
"segments": [
|
||||
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
|
||||
"segments": [
|
||||
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
|
||||
"segments": [
|
||||
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
|
||||
"segments": [
|
||||
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
|
||||
"segments": [
|
||||
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 28225000, "high_hz": 28300000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 28300000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
|
||||
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
|
||||
"segments": [
|
||||
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 50100000, "high_hz": 50300000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 50300000, "high_hz": 50600000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 50600000, "high_hz": 51000000, "mode": "Narrow", "label": "Digital" },
|
||||
{ "low_hz": 51000000, "high_hz": 54000000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
|
||||
"segments": [
|
||||
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2m", "low_hz": 144000000, "high_hz": 148000000,
|
||||
"segments": [
|
||||
{ "low_hz": 144000000, "high_hz": 144100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 144100000, "high_hz": 144275000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 144275000, "high_hz": 144400000, "mode": "Beacon", "label": "Beacon/Packet" },
|
||||
{ "low_hz": 144400000, "high_hz": 145500000, "mode": "FM", "label": "FM Simplex" },
|
||||
{ "low_hz": 145500000, "high_hz": 146000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 146000000, "high_hz": 148000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "1.25m", "low_hz": 222000000, "high_hz": 225000000,
|
||||
"segments": [
|
||||
{ "low_hz": 222000000, "high_hz": 222150000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 222150000, "high_hz": 222250000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 222250000, "high_hz": 223380000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 223380000, "high_hz": 223520000, "mode": "Narrow", "label": "Digital" },
|
||||
{ "low_hz": 223520000, "high_hz": 225000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "70cm", "low_hz": 420000000, "high_hz": 450000000,
|
||||
"segments": [
|
||||
{ "low_hz": 420000000, "high_hz": 426000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 426000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 432000000, "high_hz": 432100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 432100000, "high_hz": 433000000, "mode": "Phone", "label": "SSB/All" },
|
||||
{ "low_hz": 433000000, "high_hz": 435000000, "mode": "FM", "label": "FM/Links" },
|
||||
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 438000000, "high_hz": 444000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 444000000, "high_hz": 450000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "33cm", "low_hz": 902000000, "high_hz": 928000000,
|
||||
"segments": [
|
||||
{ "low_hz": 902000000, "high_hz": 903000000, "mode": "Narrow", "label": "Narrowband/Digital" },
|
||||
{ "low_hz": 903000000, "high_hz": 906000000, "mode": "Narrow", "label": "Digital/Spread Spectrum" },
|
||||
{ "low_hz": 906000000, "high_hz": 909000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 909000000, "high_hz": 915000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 915000000, "high_hz": 921000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 921000000, "high_hz": 927000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 927000000, "high_hz": 928000000, "mode": "FM", "label": "FM Simplex" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1240000000, "high_hz": 1260000000, "mode": "All", "label": "All Modes/ATV" },
|
||||
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 1270000000, "high_hz": 1295000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 1295000000, "high_hz": 1300000000, "mode": "Narrow", "label": "Narrowband" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"iaru_r3": {
|
||||
"name": "IARU Region 3",
|
||||
"bands": [
|
||||
{
|
||||
"name": "2200m", "low_hz": 135700, "high_hz": 137800,
|
||||
"segments": [
|
||||
{ "low_hz": 135700, "high_hz": 137800, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "630m", "low_hz": 472000, "high_hz": 479000,
|
||||
"segments": [
|
||||
{ "low_hz": 472000, "high_hz": 479000, "mode": "CW", "label": "CW/Narrow" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "160m", "low_hz": 1800000, "high_hz": 2000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1800000, "high_hz": 1840000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 1840000, "high_hz": 1850000, "mode": "Narrow", "label": "CW/Digi" },
|
||||
{ "low_hz": 1850000, "high_hz": 2000000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "80m", "low_hz": 3500000, "high_hz": 3900000,
|
||||
"segments": [
|
||||
{ "low_hz": 3500000, "high_hz": 3570000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 3570000, "high_hz": 3600000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 3600000, "high_hz": 3620000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 3620000, "high_hz": 3900000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40m", "low_hz": 7000000, "high_hz": 7300000,
|
||||
"segments": [
|
||||
{ "low_hz": 7000000, "high_hz": 7040000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 7040000, "high_hz": 7060000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 7060000, "high_hz": 7100000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 7100000, "high_hz": 7300000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "30m", "low_hz": 10100000, "high_hz": 10150000,
|
||||
"segments": [
|
||||
{ "low_hz": 10100000, "high_hz": 10140000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 10140000, "high_hz": 10150000, "mode": "Narrow", "label": "Narrow/Digi" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "20m", "low_hz": 14000000, "high_hz": 14350000,
|
||||
"segments": [
|
||||
{ "low_hz": 14000000, "high_hz": 14070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 14070000, "high_hz": 14099000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 14099000, "high_hz": 14101000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 14101000, "high_hz": 14112000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 14112000, "high_hz": 14350000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "17m", "low_hz": 18068000, "high_hz": 18168000,
|
||||
"segments": [
|
||||
{ "low_hz": 18068000, "high_hz": 18095000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 18095000, "high_hz": 18109000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 18109000, "high_hz": 18111000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 18111000, "high_hz": 18168000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "15m", "low_hz": 21000000, "high_hz": 21450000,
|
||||
"segments": [
|
||||
{ "low_hz": 21000000, "high_hz": 21070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 21070000, "high_hz": 21149000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 21149000, "high_hz": 21151000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 21151000, "high_hz": 21450000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "12m", "low_hz": 24890000, "high_hz": 24990000,
|
||||
"segments": [
|
||||
{ "low_hz": 24890000, "high_hz": 24915000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 24915000, "high_hz": 24929000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 24929000, "high_hz": 24931000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 24931000, "high_hz": 24990000, "mode": "Phone", "label": "Phone" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10m", "low_hz": 28000000, "high_hz": 29700000,
|
||||
"segments": [
|
||||
{ "low_hz": 28000000, "high_hz": 28070000, "mode": "CW", "label": "CW" },
|
||||
{ "low_hz": 28070000, "high_hz": 28190000, "mode": "Narrow", "label": "Narrow/Digi" },
|
||||
{ "low_hz": 28190000, "high_hz": 28225000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 28225000, "high_hz": 28300000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 28300000, "high_hz": 29100000, "mode": "Phone", "label": "Phone" },
|
||||
{ "low_hz": 29100000, "high_hz": 29510000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 29510000, "high_hz": 29700000, "mode": "Satellite", "label": "Satellite" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "6m", "low_hz": 50000000, "high_hz": 54000000,
|
||||
"segments": [
|
||||
{ "low_hz": 50000000, "high_hz": 50100000, "mode": "CW", "label": "CW/Beacon" },
|
||||
{ "low_hz": 50100000, "high_hz": 50300000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 50300000, "high_hz": 50600000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 50600000, "high_hz": 51000000, "mode": "Narrow", "label": "Digital" },
|
||||
{ "low_hz": 51000000, "high_hz": 54000000, "mode": "FM", "label": "FM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "FM Broadcast", "low_hz": 87500000, "high_hz": 108000000,
|
||||
"segments": [
|
||||
{ "low_hz": 87500000, "high_hz": 108000000, "mode": "FM", "label": "FM Broadcasting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2m", "low_hz": 144000000, "high_hz": 148000000,
|
||||
"segments": [
|
||||
{ "low_hz": 144000000, "high_hz": 144100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 144100000, "high_hz": 144400000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 144400000, "high_hz": 144500000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 144500000, "high_hz": 145000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 145000000, "high_hz": 146000000, "mode": "FM", "label": "FM Simplex" },
|
||||
{ "low_hz": 146000000, "high_hz": 148000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "70cm", "low_hz": 430000000, "high_hz": 450000000,
|
||||
"segments": [
|
||||
{ "low_hz": 430000000, "high_hz": 432000000, "mode": "FM", "label": "FM Repeaters" },
|
||||
{ "low_hz": 432000000, "high_hz": 432100000, "mode": "CW", "label": "CW/EME" },
|
||||
{ "low_hz": 432100000, "high_hz": 432400000, "mode": "Phone", "label": "SSB" },
|
||||
{ "low_hz": 432400000, "high_hz": 432500000, "mode": "Beacon", "label": "Beacon" },
|
||||
{ "low_hz": 432500000, "high_hz": 435000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 435000000, "high_hz": 438000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 438000000, "high_hz": 440000000, "mode": "FM", "label": "FM" },
|
||||
{ "low_hz": 440000000, "high_hz": 450000000, "mode": "FM", "label": "FM Repeaters" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "23cm", "low_hz": 1240000000, "high_hz": 1300000000,
|
||||
"segments": [
|
||||
{ "low_hz": 1240000000, "high_hz": 1260000000, "mode": "All", "label": "All Modes" },
|
||||
{ "low_hz": 1260000000, "high_hz": 1270000000, "mode": "Satellite", "label": "Satellite" },
|
||||
{ "low_hz": 1270000000, "high_hz": 1300000000, "mode": "FM", "label": "FM/ATV" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
|
||||
function decodeCborUint(view, bytes, state, additional) {
|
||||
const offset = state.offset;
|
||||
if (additional < 24) return additional;
|
||||
if (additional === 24) {
|
||||
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
state.offset += 1;
|
||||
return bytes[offset];
|
||||
}
|
||||
if (additional === 25) {
|
||||
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
state.offset += 2;
|
||||
return view.getUint16(offset);
|
||||
}
|
||||
if (additional === 26) {
|
||||
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
state.offset += 4;
|
||||
return view.getUint32(offset);
|
||||
}
|
||||
if (additional === 27) {
|
||||
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const value = view.getBigUint64(offset);
|
||||
state.offset += 8;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||
return numeric;
|
||||
}
|
||||
throw new Error("Unsupported CBOR additional info");
|
||||
}
|
||||
|
||||
function decodeCborFloat16(bits) {
|
||||
const sign = (bits & 0x8000) ? -1 : 1;
|
||||
const exponent = (bits >> 10) & 0x1f;
|
||||
const fraction = bits & 0x03ff;
|
||||
if (exponent === 0) {
|
||||
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
||||
}
|
||||
if (exponent === 0x1f) {
|
||||
return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||
}
|
||||
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
||||
}
|
||||
|
||||
function decodeCborItem(view, bytes, state) {
|
||||
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||
const initial = bytes[state.offset++];
|
||||
const major = initial >> 5;
|
||||
const additional = initial & 0x1f;
|
||||
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
||||
if (major === 2) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const chunk = bytes.slice(state.offset, state.offset + length);
|
||||
state.offset += length;
|
||||
return Array.from(chunk);
|
||||
}
|
||||
if (major === 3) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||||
state.offset += length;
|
||||
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||
}
|
||||
if (major === 4) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
const items = new Array(length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
items[i] = decodeCborItem(view, bytes, state);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (major === 5) {
|
||||
const length = decodeCborUint(view, bytes, state, additional);
|
||||
const value = {};
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const key = decodeCborItem(view, bytes, state);
|
||||
value[String(key)] = decodeCborItem(view, bytes, state);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (major === 6) {
|
||||
decodeCborUint(view, bytes, state, additional);
|
||||
return decodeCborItem(view, bytes, state);
|
||||
}
|
||||
if (major === 7) {
|
||||
if (additional === 20) return false;
|
||||
if (additional === 21) return true;
|
||||
if (additional === 22) return null;
|
||||
if (additional === 23) return undefined;
|
||||
if (additional === 25) {
|
||||
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const bits = view.getUint16(state.offset);
|
||||
state.offset += 2;
|
||||
return decodeCborFloat16(bits);
|
||||
}
|
||||
if (additional === 26) {
|
||||
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const value = view.getFloat32(state.offset);
|
||||
state.offset += 4;
|
||||
return value;
|
||||
}
|
||||
if (additional === 27) {
|
||||
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||
const value = view.getFloat64(state.offset);
|
||||
state.offset += 8;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error("Unsupported CBOR major type");
|
||||
}
|
||||
|
||||
function decodeCborPayload(buffer) {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const state = { offset: 0 };
|
||||
const value = decodeCborItem(view, bytes, state);
|
||||
if (state.offset !== bytes.length) {
|
||||
throw new Error("Unexpected trailing bytes in decode history payload");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||
self.postMessage({ type: "status", phase: "fetching" });
|
||||
const resp = await fetch(url, { credentials: "same-origin" });
|
||||
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
||||
const payload = await resp.arrayBuffer();
|
||||
if (!payload || payload.byteLength === 0) {
|
||||
self.postMessage({ type: "start", total: 0 });
|
||||
self.postMessage({ type: "done", total: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
self.postMessage({ type: "status", phase: "decoding" });
|
||||
const history = decodeCborPayload(payload);
|
||||
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
||||
return sum + items.length;
|
||||
}, 0);
|
||||
self.postMessage({ type: "start", total });
|
||||
|
||||
let processed = 0;
|
||||
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||
|
||||
for (const kind of HISTORY_GROUP_KEYS) {
|
||||
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
||||
if (items.length === 0) continue;
|
||||
for (let index = 0; index < items.length; index += safeLimit) {
|
||||
const messages = items.slice(index, index + safeLimit);
|
||||
processed += messages.length;
|
||||
self.postMessage({
|
||||
type: "group",
|
||||
kind,
|
||||
messages,
|
||||
processed,
|
||||
total,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.postMessage({ type: "done", total });
|
||||
}
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event?.data || {};
|
||||
if (data?.type !== "fetch-history") return;
|
||||
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
||||
.catch((err) => {
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
message: err && err.message ? err.message : String(err || "unknown worker failure"),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
(function() {
|
||||
if (typeof L === "undefined") return;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function finiteAngle(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const normalized = ((Number(value) % 360) + 360) % 360;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function svgColor(value, fallback) {
|
||||
const text = String(value || fallback || "");
|
||||
return text.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildSymbolHtml(options, zoom) {
|
||||
const heading = finiteAngle(options.heading);
|
||||
const course = finiteAngle(options.course);
|
||||
const angle = heading != null ? heading : course;
|
||||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||||
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
||||
const color = svgColor(options.color, "#ff7559");
|
||||
const outline = svgColor(options.outline, "#6b2118");
|
||||
|
||||
const body = angle != null
|
||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
||||
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
||||
`</g>`
|
||||
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||
|
||||
const courseLine = course != null
|
||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
||||
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
||||
`</g>`
|
||||
: "";
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
||||
courseLine +
|
||||
body +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
L.TrxAisTrackSymbol = L.Marker.extend({
|
||||
options: {
|
||||
heading: null,
|
||||
course: null,
|
||||
speed: null,
|
||||
color: "#ff7559",
|
||||
outline: "#6b2118",
|
||||
size: 22,
|
||||
interactive: true,
|
||||
keyboard: true,
|
||||
riseOnHover: true,
|
||||
},
|
||||
|
||||
initialize: function(latlng, options) {
|
||||
const merged = L.Util.extend({}, this.options, options || {});
|
||||
merged.icon = L.divIcon({
|
||||
className: "trx-ais-track-symbol-icon",
|
||||
html: "",
|
||||
iconSize: [merged.size, merged.size],
|
||||
iconAnchor: [merged.size / 2, merged.size / 2],
|
||||
});
|
||||
L.Marker.prototype.initialize.call(this, latlng, merged);
|
||||
},
|
||||
|
||||
onAdd: function(map) {
|
||||
L.Marker.prototype.onAdd.call(this, map);
|
||||
this._refreshIcon();
|
||||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||||
map.on("zoomend", this._boundZoomRefresh);
|
||||
},
|
||||
|
||||
onRemove: function(map) {
|
||||
if (this._boundZoomRefresh) {
|
||||
map.off("zoomend", this._boundZoomRefresh);
|
||||
this._boundZoomRefresh = null;
|
||||
}
|
||||
L.Marker.prototype.onRemove.call(this, map);
|
||||
},
|
||||
|
||||
setAisState: function(next) {
|
||||
if (next && typeof next === "object") {
|
||||
if ("heading" in next) this.options.heading = next.heading;
|
||||
if ("course" in next) this.options.course = next.course;
|
||||
if ("speed" in next) this.options.speed = next.speed;
|
||||
if ("color" in next) this.options.color = next.color;
|
||||
if ("outline" in next) this.options.outline = next.outline;
|
||||
}
|
||||
this._refreshIcon();
|
||||
return this;
|
||||
},
|
||||
|
||||
_refreshIcon: function() {
|
||||
if (!this._icon) return;
|
||||
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
||||
const html = buildSymbolHtml(this.options, zoom);
|
||||
this._icon.innerHTML = html;
|
||||
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
|
||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||
this._icon.style.width = `${size}px`;
|
||||
this._icon.style.height = `${size}px`;
|
||||
this._icon.style.marginLeft = `${-size / 2}px`;
|
||||
this._icon.style.marginTop = `${-size / 2}px`;
|
||||
},
|
||||
});
|
||||
|
||||
L.trxAisTrackSymbol = function(latlng, options) {
|
||||
return new L.TrxAisTrackSymbol(latlng, options);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,407 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- AIS Decoder Plugin (server-side decode) ---
|
||||
const aisStatus = document.getElementById("ais-status");
|
||||
const aisMessagesEl = document.getElementById("ais-messages");
|
||||
const aisFilterInput = document.getElementById("ais-filter");
|
||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const AIS_DEFAULT_A_HZ = 161_975_000;
|
||||
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||
let aisFilterText = "";
|
||||
let aisMessageHistory = [];
|
||||
|
||||
function currentAisHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneAisMessageHistory() {
|
||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleAisUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleAisHistoryRender() {
|
||||
scheduleAisUi("ais-history", () => renderAisHistory());
|
||||
}
|
||||
|
||||
function scheduleAisBarUpdate() {
|
||||
scheduleAisUi("ais-bar", () => updateAisBar());
|
||||
}
|
||||
|
||||
function formatAisMhz(freqHz) {
|
||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||
}
|
||||
|
||||
function currentAisChannelPlan() {
|
||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||
return {
|
||||
aHz: safeAHz,
|
||||
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ,
|
||||
};
|
||||
}
|
||||
|
||||
function aisChannelInfo(channel) {
|
||||
const plan = currentAisChannelPlan();
|
||||
const ch = String(channel || "").trim().toUpperCase();
|
||||
if (ch === "B") {
|
||||
return {
|
||||
label: "AIS-B",
|
||||
badgeClass: "ais-badge ais-badge-channel-b",
|
||||
freqText: formatAisMhz(plan.bHz),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "AIS-A",
|
||||
badgeClass: "ais-badge ais-badge-channel-a",
|
||||
freqText: formatAisMhz(plan.aHz),
|
||||
};
|
||||
}
|
||||
|
||||
function aisDisplayName(msg) {
|
||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||
}
|
||||
|
||||
function aisDisplayNameHtml(msg) {
|
||||
const label = escapeMapHtml(aisDisplayName(msg));
|
||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||
if (!url) return label;
|
||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||
}
|
||||
|
||||
function aisTypeLabel(type) {
|
||||
switch (Number(type)) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
return "Class A Position";
|
||||
case 4:
|
||||
return "Base Station";
|
||||
case 5:
|
||||
return "Static/Voyage";
|
||||
case 18:
|
||||
return "Class B Position";
|
||||
case 19:
|
||||
return "Class B Extended";
|
||||
case 21:
|
||||
return "Aid to Nav";
|
||||
case 24:
|
||||
return "Class B Static";
|
||||
default:
|
||||
return `Type ${type ?? "--"}`;
|
||||
}
|
||||
}
|
||||
|
||||
function aisAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function aisMotionText(msg) {
|
||||
const parts = [
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
||||
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function aisRouteText(msg) {
|
||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||
}
|
||||
|
||||
function aisDistanceText(msg) {
|
||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
||||
return "";
|
||||
}
|
||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function aisLatestByVessel(messages) {
|
||||
const byMmsi = new Map();
|
||||
for (const msg of messages) {
|
||||
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||
}
|
||||
return Array.from(byMmsi.values());
|
||||
}
|
||||
|
||||
function updateAisSummary() {
|
||||
const plan = currentAisChannelPlan();
|
||||
if (aisChannelSummaryEl) {
|
||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||
}
|
||||
|
||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||
if (aisVesselCountEl) {
|
||||
const count = vessels.length;
|
||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
if (aisLatestSeenEl) {
|
||||
const latest = aisMessageHistory[0];
|
||||
if (!latest) {
|
||||
aisLatestSeenEl.textContent = "No traffic yet";
|
||||
} else {
|
||||
const channel = aisChannelInfo(latest.channel);
|
||||
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAisRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ais-message";
|
||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const name = aisDisplayName(msg);
|
||||
const nameHtml = aisDisplayNameHtml(msg);
|
||||
const channel = aisChannelInfo(msg.channel);
|
||||
const motion = aisMotionText(msg);
|
||||
const route = aisRouteText(msg);
|
||||
const distance = aisDistanceText(msg);
|
||||
const pos = msg.lat != null && msg.lon != null
|
||||
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
row.dataset.filterText = [
|
||||
name,
|
||||
msg.mmsi,
|
||||
msg.channel,
|
||||
channel.label,
|
||||
msg.vessel_name,
|
||||
msg.callsign,
|
||||
msg.destination,
|
||||
aisTypeLabel(msg.message_type),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
row.innerHTML =
|
||||
`<div class="ais-row-head">` +
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<span class="ais-call">${nameHtml}</span>` +
|
||||
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="ais-row-meta">` +
|
||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
||||
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
||||
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="ais-row-detail">` +
|
||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
||||
(pos ? `<span>${pos}</span>` : "") +
|
||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||
`</div>`;
|
||||
applyAisFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function applyAisFilterToRow(row) {
|
||||
if (!aisFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.filterText || "";
|
||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyAisFilterToAll() {
|
||||
if (!aisMessagesEl) return;
|
||||
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
||||
rows.forEach((row) => applyAisFilterToRow(row));
|
||||
}
|
||||
|
||||
function updateAisBar() {
|
||||
if (!aisBarOverlay) return;
|
||||
updateAisSummary();
|
||||
|
||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
if (!isAis || messages.length === 0) {
|
||||
aisBarOverlay.style.display = "none";
|
||||
aisBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||
for (const msg of messages) {
|
||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||
const pin = msg.lat != null && msg.lon != null
|
||||
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
|
||||
: "";
|
||||
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||
const channel = aisChannelInfo(msg.channel);
|
||||
const distance = aisDistanceText(msg);
|
||||
const details = [
|
||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||
escapeMapHtml(channel.label),
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||
distance ? escapeMapHtml(distance) : null,
|
||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
aisBarOverlay.innerHTML = html;
|
||||
aisBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateAisBar = updateAisBar;
|
||||
window.clearAisBar = function() {
|
||||
window.resetAisHistoryView();
|
||||
};
|
||||
|
||||
window.resetAisHistoryView = function() {
|
||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||
aisMessageHistory = [];
|
||||
updateAisBar();
|
||||
renderAisHistory();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
||||
};
|
||||
|
||||
function renderAisHistory() {
|
||||
pruneAisMessageHistory();
|
||||
if (!aisMessagesEl) {
|
||||
updateAisSummary();
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
||||
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
||||
}
|
||||
aisMessagesEl.replaceChildren(fragment);
|
||||
updateAisSummary();
|
||||
}
|
||||
|
||||
function addAisMessage(msg) {
|
||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
aisMessageHistory.unshift(msg);
|
||||
pruneAisMessageHistory();
|
||||
scheduleAisBarUpdate();
|
||||
scheduleAisHistoryRender();
|
||||
|
||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
||||
window.aisMapAddVessel(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerAisMessage(msg) {
|
||||
return {
|
||||
rig_id: msg.rig_id || null,
|
||||
channel: msg.channel,
|
||||
message_type: msg.message_type,
|
||||
mmsi: msg.mmsi,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
sog_knots: msg.sog_knots,
|
||||
cog_deg: msg.cog_deg,
|
||||
heading_deg: msg.heading_deg,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
ts_ms: msg.ts_ms,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerAisBatch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerAisMessage(msg);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
||||
window.aisMapAddVessel(next);
|
||||
}
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||
pruneAisMessageHistory();
|
||||
scheduleAisBarUpdate();
|
||||
scheduleAisHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreAisHistory = function(messages) {
|
||||
window.onServerAisBatch(messages);
|
||||
};
|
||||
|
||||
window.pruneAisHistoryView = function() {
|
||||
pruneAisMessageHistory();
|
||||
updateAisBar();
|
||||
renderAisHistory();
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ais_decode");
|
||||
window.resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (aisFilterInput) {
|
||||
aisFilterInput.addEventListener("input", () => {
|
||||
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||
renderAisHistory();
|
||||
});
|
||||
}
|
||||
|
||||
window.onServerAis = function(msg) {
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage(normalizeServerAisMessage(msg));
|
||||
};
|
||||
|
||||
updateAisSummary();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
||||
@@ -0,0 +1,498 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- APRS Decoder Plugin (server-side decode) ---
|
||||
const aprsStatus = document.getElementById("aprs-status");
|
||||
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||
const aprsFilterInput = document.getElementById("aprs-filter");
|
||||
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||
const aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
let aprsFilterText = "";
|
||||
let aprsPacketHistory = [];
|
||||
let aprsBarDismissedAtMs = 0;
|
||||
let aprsOnlyPos = false;
|
||||
let aprsHideCrc = false;
|
||||
let aprsCollapseDup = false;
|
||||
let aprsTypeFilter = "all";
|
||||
|
||||
function currentAprsHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneAprsPacketHistory() {
|
||||
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleAprsUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleAprsHistoryRender() {
|
||||
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
||||
}
|
||||
|
||||
function scheduleAprsBarUpdate() {
|
||||
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
||||
}
|
||||
|
||||
function renderAprsInfo(pkt) {
|
||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||
if (bytes && bytes.length > 0) {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i];
|
||||
if (b >= 0x20 && b <= 0x7e) {
|
||||
const ch = String.fromCharCode(b);
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const str = pkt.info || "";
|
||||
let out = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
const ch = str[i];
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function aprsPacketCategory(pkt) {
|
||||
const type = String(pkt.type || "").toLowerCase();
|
||||
const info = String(pkt.info || "").toLowerCase();
|
||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function aprsCategoryLabel(category) {
|
||||
switch (category) {
|
||||
case "position": return "Position";
|
||||
case "message": return "Message";
|
||||
case "weather": return "Weather";
|
||||
case "telemetry": return "Telemetry";
|
||||
default: return "Other";
|
||||
}
|
||||
}
|
||||
|
||||
function aprsAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function aprsDistanceText(pkt) {
|
||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function aprsPacketSignature(pkt) {
|
||||
return [
|
||||
pkt.srcCall || "",
|
||||
pkt.destCall || "",
|
||||
pkt.path || "",
|
||||
pkt.info || "",
|
||||
pkt.type || "",
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function aprsHexBytes(bytes) {
|
||||
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||
}
|
||||
|
||||
function aprsFilterMatch(pkt) {
|
||||
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||
if (!aprsFilterText) return true;
|
||||
const haystack = [
|
||||
pkt.srcCall,
|
||||
pkt.destCall,
|
||||
pkt.path,
|
||||
pkt.info,
|
||||
pkt.type,
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
aprsPacketCategory(pkt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
return haystack.includes(aprsFilterText);
|
||||
}
|
||||
|
||||
function aprsVisiblePackets() {
|
||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||
return packets.filter(aprsFilterMatch);
|
||||
}
|
||||
|
||||
function collapseAprsDuplicates(packets) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pkt of packets) {
|
||||
const key = aprsPacketSignature(pkt);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(pkt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateAprsSummary() {
|
||||
const visible = aprsVisiblePackets();
|
||||
if (aprsTotalCountEl) {
|
||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||
}
|
||||
if (aprsVisibleCountEl) {
|
||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (aprsLatestSeenEl) {
|
||||
const latest = aprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
aprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateAprsChipState() {
|
||||
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
||||
});
|
||||
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
||||
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||
}
|
||||
|
||||
function renderAprsRow(pkt, isFresh) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
|
||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = aprsAgeText(pkt._tsMs);
|
||||
const category = aprsPacketCategory(pkt);
|
||||
const categoryLabel = aprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||
let symbolHtml = "";
|
||||
if (pkt.symbolTable && pkt.symbolCode) {
|
||||
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||
const col = code % 16;
|
||||
const row2 = Math.floor(code / 16);
|
||||
const bgX = -(col * 24);
|
||||
const bgY = -(row2 * 24);
|
||||
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||
}
|
||||
const posLink = pkt.lat != null && pkt.lon != null
|
||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
const distance = aprsDistanceText(pkt);
|
||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||
|
||||
row.innerHTML =
|
||||
`<div class="aprs-row-head">` +
|
||||
`<span class="aprs-time">${ts}</span>` +
|
||||
symbolHtml +
|
||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||
pathBadge +
|
||||
crcBadge +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-meta">` +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-detail">` +
|
||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
||||
(posLink ? `<span>${posLink}</span>` : "") +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-actions">` +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||
`</div>` +
|
||||
`<details class="aprs-details">` +
|
||||
`<summary>Details</summary>` +
|
||||
`<div class="aprs-details-grid">` +
|
||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
||||
`</div>` +
|
||||
`</details>`;
|
||||
|
||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||
el.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
const raw = String(el.dataset.aprsMap || "");
|
||||
const [lat, lon] = raw.split(",").map(Number);
|
||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
showHint("Coordinates copied", 1200);
|
||||
}
|
||||
} catch (_e) {
|
||||
showHint("Copy failed", 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAprsHistory() {
|
||||
pruneAprsPacketHistory();
|
||||
if (!aprsPacketsEl) {
|
||||
updateAprsSummary();
|
||||
updateAprsChipState();
|
||||
return;
|
||||
}
|
||||
const visible = aprsVisiblePackets();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
||||
}
|
||||
aprsPacketsEl.replaceChildren(fragment);
|
||||
updateAprsSummary();
|
||||
updateAprsChipState();
|
||||
}
|
||||
|
||||
function updateAprsBar() {
|
||||
if (!aprsBarOverlay) return;
|
||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||
aprsBarOverlay.style.display = "none";
|
||||
aprsBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
||||
for (const pkt of frames) {
|
||||
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
||||
const dest = escapeMapHtml(pkt.destCall || "");
|
||||
const info = escapeMapHtml(pkt.info || "");
|
||||
const pin = pkt.lat != null && pkt.lon != null
|
||||
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
||||
: "";
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
aprsBarOverlay.innerHTML = html;
|
||||
aprsBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateAprsBar = updateAprsBar;
|
||||
window.clearAprsBar = function() {
|
||||
window.resetAprsHistoryView();
|
||||
};
|
||||
window.closeAprsBar = function() {
|
||||
aprsBarDismissedAtMs = Date.now();
|
||||
if (aprsBarOverlay) {
|
||||
aprsBarOverlay.style.display = "none";
|
||||
aprsBarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
window.resetAprsHistoryView = function() {
|
||||
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||
aprsPacketHistory = [];
|
||||
updateAprsBar();
|
||||
renderAprsHistory();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
||||
};
|
||||
|
||||
window.pruneAprsHistoryView = function() {
|
||||
pruneAprsPacketHistory();
|
||||
updateAprsBar();
|
||||
renderAprsHistory();
|
||||
};
|
||||
|
||||
function addAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
|
||||
aprsPacketHistory.unshift(pkt);
|
||||
pruneAprsPacketHistory();
|
||||
|
||||
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
||||
}
|
||||
|
||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||
|
||||
scheduleAprsHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerAprsPacket(pkt) {
|
||||
return {
|
||||
rig_id: pkt.rig_id || null,
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
srcCall: pkt.src_call,
|
||||
destCall: pkt.dest_call,
|
||||
path: pkt.path,
|
||||
info: pkt.info,
|
||||
info_bytes: pkt.info_bytes,
|
||||
type: pkt.packet_type,
|
||||
crcOk: pkt.crc_ok,
|
||||
ts_ms: pkt.ts_ms,
|
||||
lat: pkt.lat,
|
||||
lon: pkt.lon,
|
||||
symbolTable: pkt.symbol_table,
|
||||
symbolCode: pkt.symbol_code,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerAprsBatch = function(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
aprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
let hasCrcOk = false;
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerAprsPacket(pkt);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
||||
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
||||
}
|
||||
if (next.crcOk) hasCrcOk = true;
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
aprsPacketHistory = normalized.concat(aprsPacketHistory);
|
||||
pruneAprsPacketHistory();
|
||||
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||
scheduleAprsHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreAprsHistory = function(packets) {
|
||||
window.onServerAprsBatch(packets);
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_aprs_decode");
|
||||
window.resetAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("APRS history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (aprsOnlyPosBtn) {
|
||||
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||
aprsOnlyPos = !aprsOnlyPos;
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (aprsHideCrcBtn) {
|
||||
aprsHideCrcBtn.addEventListener("click", () => {
|
||||
aprsHideCrc = !aprsHideCrc;
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (aprsCollapseDupBtn) {
|
||||
aprsCollapseDupBtn.addEventListener("click", () => {
|
||||
aprsCollapseDup = !aprsCollapseDup;
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||
const btn = document.getElementById(`aprs-type-${type}`);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
aprsTypeFilter = type;
|
||||
renderAprsHistory();
|
||||
});
|
||||
});
|
||||
|
||||
if (aprsFilterInput) {
|
||||
aprsFilterInput.addEventListener("input", () => {
|
||||
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
||||
renderAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Server-side APRS decode handler ---
|
||||
window.onServerAprs = function(pkt) {
|
||||
aprsStatus.textContent = "Receiving";
|
||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||
};
|
||||
|
||||
renderAprsHistory();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
||||
@@ -0,0 +1,410 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function bgdSupportedIds() {
|
||||
return (window.decoderRegistry || [])
|
||||
.filter(function (d) { return d.background_decode; })
|
||||
.map(function (d) { return d.id; });
|
||||
}
|
||||
|
||||
let backgroundDecodeRole = null;
|
||||
let currentRigId = null;
|
||||
let currentConfig = null;
|
||||
let bookmarkList = [];
|
||||
let statusInterval = null;
|
||||
let bgdDirty = false;
|
||||
|
||||
function initBackgroundDecode(rigId, role) {
|
||||
backgroundDecodeRole = role;
|
||||
currentRigId = rigId || null;
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
}
|
||||
|
||||
function setBackgroundDecodeRig(rigId) {
|
||||
const nextRigId = rigId || null;
|
||||
if (nextRigId === currentRigId) return;
|
||||
currentRigId = nextRigId;
|
||||
if (!currentRigId) return;
|
||||
loadBackgroundDecode();
|
||||
}
|
||||
|
||||
function apiGetConfig(rigId) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiPutConfig(rigId, config) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiResetConfig(rigId) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||
method: "DELETE",
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiGetStatus(rigId) {
|
||||
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiGetBookmarks() {
|
||||
return fetch("/bookmarks").then(function (r) {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function loadBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
||||
.then(function ([config, bookmarks]) {
|
||||
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
||||
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||
renderBackgroundDecode();
|
||||
clearBgdDirty();
|
||||
pollBackgroundDecodeStatus();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("background decode load failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function supportedBookmarks() {
|
||||
return bookmarkList.filter(function (bookmark) {
|
||||
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function bookmarkDecoderKinds(bookmark) {
|
||||
var ids = bgdSupportedIds();
|
||||
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
||||
var explicit = decoders
|
||||
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
||||
.filter(function (item, index, arr) {
|
||||
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||
});
|
||||
if (explicit.length > 0) return explicit;
|
||||
// Fall back: infer from mode via mode-bound entries in the registry.
|
||||
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
||||
return (window.decoderRegistry || [])
|
||||
.filter(function (d) {
|
||||
return d.activation === "mode_bound" && d.background_decode
|
||||
&& d.active_modes.indexOf(mode) >= 0;
|
||||
})
|
||||
.map(function (d) { return d.id; });
|
||||
}
|
||||
|
||||
function renderBackgroundDecode() {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
||||
renderBookmarkChecklist();
|
||||
|
||||
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
|
||||
const panel = document.getElementById("background-decode-panel");
|
||||
if (panel) {
|
||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
|
||||
el.disabled = !isControl;
|
||||
});
|
||||
}
|
||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||
}
|
||||
|
||||
function renderBookmarkChecklist(filterText) {
|
||||
const container = document.getElementById("bgd-bookmark-checklist");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
const selectedIds = new Set(
|
||||
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
||||
);
|
||||
const all = supportedBookmarks();
|
||||
const filter = (filterText || "").trim().toLowerCase();
|
||||
|
||||
const filtered = filter
|
||||
? all.filter(function (bm) {
|
||||
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||
return text.indexOf(filter) >= 0;
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(function (bookmark) {
|
||||
var row = document.createElement("label");
|
||||
row.className = "bgd-checklist-row";
|
||||
var decoders = bookmarkDecoderKinds(bookmark);
|
||||
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||
row.innerHTML =
|
||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||
row.querySelector("input").addEventListener("change", function (e) {
|
||||
onChecklistToggle(bookmark.id, e.target.checked);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function onChecklistToggle(bookmarkId, checked) {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
||||
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
||||
currentConfig.bookmark_ids.push(bookmarkId);
|
||||
} else if (!checked) {
|
||||
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
|
||||
}
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
function saveBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
const payload = {
|
||||
remote: rigId,
|
||||
enabled: !!document.getElementById("background-decode-enabled").checked,
|
||||
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
|
||||
};
|
||||
const btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.disabled = true;
|
||||
apiPutConfig(rigId, payload)
|
||||
.then(function (saved) {
|
||||
currentConfig = saved;
|
||||
renderBackgroundDecode();
|
||||
clearBgdDirty();
|
||||
pollBackgroundDecodeStatus();
|
||||
showToast("Background decode saved.");
|
||||
})
|
||||
.catch(function (err) {
|
||||
showToast("Save failed: " + err.message, true);
|
||||
})
|
||||
.finally(function () {
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetBackgroundDecode() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
|
||||
apiResetConfig(rigId)
|
||||
.then(function (saved) {
|
||||
currentConfig = saved;
|
||||
renderBackgroundDecode();
|
||||
clearBgdDirty();
|
||||
pollBackgroundDecodeStatus();
|
||||
showToast("Background decode reset.");
|
||||
})
|
||||
.catch(function (err) {
|
||||
showToast("Reset failed: " + err.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
if (statusInterval) clearInterval(statusInterval);
|
||||
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
|
||||
}
|
||||
|
||||
function pollBackgroundDecodeStatus() {
|
||||
const rigId = currentRigId;
|
||||
if (!rigId) return;
|
||||
apiGetStatus(rigId)
|
||||
.then(renderStatus)
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
const card = document.getElementById("background-decode-status-card");
|
||||
if (!card) return;
|
||||
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
||||
if (!entries.length) {
|
||||
card.textContent = "No background decode bookmarks configured.";
|
||||
return;
|
||||
}
|
||||
const summary = [];
|
||||
if (status.active_rig) {
|
||||
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||
} else {
|
||||
summary.push("This rig is not currently selected for audio.");
|
||||
}
|
||||
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||
html += '<div class="bgd-status-list">';
|
||||
entries.forEach(function (entry) {
|
||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||
const parts = [];
|
||||
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||
if (entry.mode) parts.push(entry.mode);
|
||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||
}
|
||||
html +=
|
||||
'<div class="bgd-status-row">' +
|
||||
'<div>' +
|
||||
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
||||
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
||||
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
||||
escHtml(prettyState(entry.state)) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += "</div>";
|
||||
card.innerHTML = html;
|
||||
}
|
||||
|
||||
function prettyState(state) {
|
||||
switch (state) {
|
||||
case "active": return "\u2713 Active";
|
||||
case "out_of_span": return "\u25B3 Out of span";
|
||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
||||
case "waiting_for_user": return "\u25B3 No user";
|
||||
case "missing_bookmark": return "\u2717 Missing";
|
||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
||||
case "disabled": return "\u25B3 Disabled";
|
||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
||||
default: return "\u25B3 Inactive";
|
||||
}
|
||||
}
|
||||
|
||||
function setCheckbox(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = !!value;
|
||||
}
|
||||
|
||||
function formatFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||
return hz + " Hz";
|
||||
}
|
||||
|
||||
function escHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function markBgdDirty() {
|
||||
if (bgdDirty) return;
|
||||
bgdDirty = true;
|
||||
var btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.add("sch-dirty");
|
||||
}
|
||||
|
||||
function clearBgdDirty() {
|
||||
bgdDirty = false;
|
||||
var btn = document.getElementById("background-decode-save-btn");
|
||||
if (btn) btn.classList.remove("sch-dirty");
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
||||
el.style.display = "block";
|
||||
setTimeout(function () {
|
||||
el.style.display = "none";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function selectAllBookmarks() {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||
currentConfig.bookmark_ids = ids;
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
function deselectAllBookmarks() {
|
||||
if (!currentConfig) {
|
||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||
}
|
||||
currentConfig.bookmark_ids = [];
|
||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||
markBgdDirty();
|
||||
}
|
||||
|
||||
function wireBackgroundDecodeEvents() {
|
||||
const filterInput = document.getElementById("bgd-bookmark-filter");
|
||||
if (filterInput && !filterInput._wired) {
|
||||
filterInput._wired = true;
|
||||
filterInput.addEventListener("input", function () {
|
||||
renderBookmarkChecklist(filterInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
const enabledCb = document.getElementById("background-decode-enabled");
|
||||
if (enabledCb && !enabledCb._wired) {
|
||||
enabledCb._wired = true;
|
||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||
}
|
||||
|
||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||
if (selectAllBtn && !selectAllBtn._wired) {
|
||||
selectAllBtn._wired = true;
|
||||
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||
}
|
||||
|
||||
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
||||
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||
deselectAllBtn._wired = true;
|
||||
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||
if (saveBtn && !saveBtn._wired) {
|
||||
saveBtn._wired = true;
|
||||
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||
}
|
||||
|
||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||
if (resetBtn && !resetBtn._wired) {
|
||||
resetBtn._wired = true;
|
||||
resetBtn.addEventListener("click", resetBackgroundDecode);
|
||||
}
|
||||
}
|
||||
|
||||
window.initBackgroundDecode = initBackgroundDecode;
|
||||
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||
})();
|
||||
@@ -0,0 +1,792 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- Bookmarks Tab ---
|
||||
|
||||
/** Current bookmark scope: "general" or a rig remote name. */
|
||||
let bmScope = "general";
|
||||
|
||||
/** Build the ?scope= query string for a given or current bookmark scope. */
|
||||
function bmScopeParam(prefix, scope) {
|
||||
const sep = prefix ? "&" : "?";
|
||||
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||
}
|
||||
|
||||
var bmList = [];
|
||||
var bmRevision = 0;
|
||||
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
||||
var bmOverlayList = [];
|
||||
var bmOverlayRevision = 0;
|
||||
let bmFilteredList = [];
|
||||
let bmEditId = null;
|
||||
let bmEditScope = null;
|
||||
let bmCurrentPage = 1;
|
||||
const BM_PAGE_SIZE = 25;
|
||||
const bmSelected = new Set();
|
||||
|
||||
function bmFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
function bmEsc(str) {
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(document.createTextNode(String(str)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function bmCanControl() {
|
||||
return (
|
||||
(typeof authEnabled !== "undefined" && !authEnabled) ||
|
||||
(typeof authRole !== "undefined" && authRole === "control")
|
||||
);
|
||||
}
|
||||
|
||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
const addBtn = document.getElementById("bm-add-btn");
|
||||
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||
}
|
||||
|
||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||
function bmListScope() {
|
||||
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
|
||||
return rig || "general";
|
||||
}
|
||||
|
||||
async function bmFetchOverlay() {
|
||||
const overlayScope = bmListScope();
|
||||
try {
|
||||
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
bmOverlayList = await resp.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch overlay bookmarks:", e);
|
||||
bmOverlayList = [];
|
||||
}
|
||||
bmOverlayRevision++;
|
||||
if (typeof window.syncBookmarkMapLocators === "function") {
|
||||
window.syncBookmarkMapLocators(bmOverlayList);
|
||||
}
|
||||
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
async function bmFetch(categoryFilter) {
|
||||
let url = "/bookmarks";
|
||||
let hasQuery = false;
|
||||
if (categoryFilter && categoryFilter !== "") {
|
||||
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||
hasQuery = true;
|
||||
}
|
||||
url += bmScopeParam(hasQuery);
|
||||
const overlayPromise = bmFetchOverlay();
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
bmList = await resp.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch bookmarks:", e);
|
||||
bmList = [];
|
||||
}
|
||||
bmRevision++;
|
||||
bmSelected.clear();
|
||||
bmUpdateSelectionUi();
|
||||
bmSyncAccess();
|
||||
bmApplyFilters();
|
||||
bmRefreshCategoryFilter(categoryFilter);
|
||||
await overlayPromise;
|
||||
}
|
||||
|
||||
function bmApplyFilters() {
|
||||
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||
let filtered = modeFilter
|
||||
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
|
||||
: bmList;
|
||||
filtered = text
|
||||
? filtered.filter((bm) =>
|
||||
(bm.name || "").toLowerCase().includes(text) ||
|
||||
(bm.locator || "").toLowerCase().includes(text) ||
|
||||
(bm.category || "").toLowerCase().includes(text) ||
|
||||
(bm.comment || "").toLowerCase().includes(text)
|
||||
)
|
||||
: filtered;
|
||||
bmFilteredList = filtered;
|
||||
bmCurrentPage = 1;
|
||||
bmRender(filtered);
|
||||
}
|
||||
|
||||
async function bmRefreshCategoryFilter(keepValue) {
|
||||
const sel = document.getElementById("bm-category-filter");
|
||||
const modeSel = document.getElementById("bm-mode-filter");
|
||||
if (!sel && !modeSel) return;
|
||||
try {
|
||||
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||
if (!resp.ok) return;
|
||||
const all = await resp.json();
|
||||
if (sel) {
|
||||
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
cats.forEach((cat) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = cat;
|
||||
opt.textContent = cat;
|
||||
sel.add(opt);
|
||||
});
|
||||
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||
}
|
||||
if (modeSel) {
|
||||
const keepMode = modeSel.value;
|
||||
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||
modes.forEach((mode) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = mode;
|
||||
opt.textContent = mode;
|
||||
modeSel.add(opt);
|
||||
});
|
||||
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function bmRender(list) {
|
||||
const tbody = document.getElementById("bm-tbody");
|
||||
const emptyEl = document.getElementById("bm-empty");
|
||||
const paginatorEl = document.getElementById("bm-paginator");
|
||||
const pageSummaryEl = document.getElementById("bm-page-summary");
|
||||
const pageIndicatorEl = document.getElementById("bm-page-indicator");
|
||||
const prevBtn = document.getElementById("bm-page-prev");
|
||||
const nextBtn = document.getElementById("bm-page-next");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (list.length === 0) {
|
||||
if (emptyEl) emptyEl.style.display = "";
|
||||
if (paginatorEl) paginatorEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
|
||||
const canControl = bmCanControl();
|
||||
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
|
||||
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
|
||||
bmCurrentPage = page;
|
||||
const startIndex = (page - 1) * BM_PAGE_SIZE;
|
||||
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
|
||||
const pageItems = list.slice(startIndex, endIndex);
|
||||
|
||||
const showScope = bmScope !== "general";
|
||||
pageItems.forEach((bm) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.bmId = bm.id;
|
||||
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||
const locatorCell = bm.locator || "--";
|
||||
const catCell = bm.category || "Uncategorised";
|
||||
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||
const commentCell = bm.comment || "";
|
||||
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
|
||||
tr.innerHTML =
|
||||
`<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td>` +
|
||||
`<td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td>` +
|
||||
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
|
||||
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
||||
`<td class="bm-col-bw">${bwCell}</td>` +
|
||||
`<td class="bm-col-loc">${bmEsc(locatorCell)}</td>` +
|
||||
`<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
|
||||
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
|
||||
`<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
|
||||
`<td class="bm-col-act">` +
|
||||
`<button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` +
|
||||
(canControl
|
||||
? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button>` +
|
||||
`<button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>`
|
||||
: "") +
|
||||
`</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
bmSyncSelectAllCheckbox();
|
||||
|
||||
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
|
||||
if (prevBtn) prevBtn.disabled = page <= 1;
|
||||
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||
}
|
||||
|
||||
function bmChangePage(delta) {
|
||||
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||
if (nextPage === bmCurrentPage) return;
|
||||
bmCurrentPage = nextPage;
|
||||
bmRender(bmFilteredList);
|
||||
}
|
||||
|
||||
// Read decoder checkboxes and return an array of selected decoder names.
|
||||
function bmReadDecoders() {
|
||||
return (window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
|
||||
.map(d => d.id);
|
||||
}
|
||||
|
||||
// Set decoder checkboxes to match the given array.
|
||||
function bmWriteDecoders(decoders) {
|
||||
const set = new Set(decoders || []);
|
||||
(window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const el = document.getElementById("bm-dec-" + d.id);
|
||||
if (el) el.checked = set.has(d.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Build decoder checkboxes dynamically from the registry.
|
||||
function bmBuildDecoderCheckboxes() {
|
||||
const container = document.getElementById("bm-decoder-checkboxes");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
(window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable)
|
||||
.forEach(d => {
|
||||
const label = document.createElement("label");
|
||||
label.className = "bm-decoder-check";
|
||||
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||
container.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function bmOpenForm(bm) {
|
||||
const wrap = document.getElementById("bm-form-wrap");
|
||||
if (!wrap) return;
|
||||
bmEditId = bm ? bm.id : null;
|
||||
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
||||
|
||||
// Rebuild decoder checkboxes from registry (handles race where registry
|
||||
// loaded after initial build).
|
||||
bmBuildDecoderCheckboxes();
|
||||
|
||||
document.getElementById("bm-id").value = bm ? bm.id : "";
|
||||
document.getElementById("bm-name").value = bm ? bm.name : "";
|
||||
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
||||
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
||||
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
|
||||
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||
bmWriteDecoders(bm ? bm.decoders : []);
|
||||
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||
|
||||
wrap.style.display = "flex";
|
||||
document.getElementById("bm-name").focus();
|
||||
}
|
||||
|
||||
function bmCloseForm() {
|
||||
const wrap = document.getElementById("bm-form-wrap");
|
||||
if (wrap) wrap.style.display = "none";
|
||||
bmEditId = null;
|
||||
}
|
||||
|
||||
function bmPrefillFromStatus() {
|
||||
// Use globals maintained by app.js (updated by SSE stream)
|
||||
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
||||
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
||||
}
|
||||
if (typeof lastModeName === "string" && lastModeName) {
|
||||
document.getElementById("bm-mode").value = lastModeName;
|
||||
}
|
||||
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
||||
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
||||
}
|
||||
// Prefill decoder checkboxes from current toggle button state.
|
||||
const activeDecoders = (window.decoderRegistry || [])
|
||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||
.filter(d => {
|
||||
const btn = document.getElementById(d.id + "-decode-toggle-btn");
|
||||
return btn && btn.dataset.enabled === "true";
|
||||
})
|
||||
.map(d => d.id);
|
||||
bmWriteDecoders(activeDecoders);
|
||||
}
|
||||
|
||||
async function bmSave(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById("bm-id").value;
|
||||
const name = document.getElementById("bm-name").value.trim();
|
||||
const freqStr = document.getElementById("bm-freq").value;
|
||||
const freq_hz = parseInt(freqStr, 10);
|
||||
const mode = document.getElementById("bm-mode").value.trim();
|
||||
const bwStr = document.getElementById("bm-bw").value;
|
||||
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
|
||||
const category = document.getElementById("bm-category-input").value.trim();
|
||||
const comment = document.getElementById("bm-comment").value.trim();
|
||||
const decoders = bmReadDecoders();
|
||||
|
||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||
alert("Name, Frequency, and Mode are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
name,
|
||||
freq_hz,
|
||||
mode,
|
||||
bandwidth_hz,
|
||||
locator: locator || null,
|
||||
category,
|
||||
comment,
|
||||
decoders,
|
||||
};
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (id) {
|
||||
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
resp = await fetch("/bookmarks" + bmScopeParam(false), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (resp.status === 409) {
|
||||
throw new Error("A bookmark for that frequency already exists.");
|
||||
}
|
||||
throw new Error(text || "HTTP " + resp.status);
|
||||
}
|
||||
bmCloseForm();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to save bookmark:", err);
|
||||
alert("Failed to save bookmark: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmDelete(id) {
|
||||
if (!confirm("Delete this bookmark?")) return;
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm ? bm.scope : undefined;
|
||||
try {
|
||||
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmark:", err);
|
||||
alert("Failed to delete bookmark: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmApply(bm) {
|
||||
try {
|
||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||
if (typeof modeEl !== "undefined" && modeEl) {
|
||||
modeEl.value = String(bm.mode || "").toUpperCase();
|
||||
}
|
||||
if (bm.bandwidth_hz) {
|
||||
if (typeof currentBandwidthHz !== "undefined") {
|
||||
currentBandwidthHz = bm.bandwidth_hz;
|
||||
}
|
||||
window.currentBandwidthHz = bm.bandwidth_hz;
|
||||
if (typeof syncBandwidthInput === "function") {
|
||||
syncBandwidthInput(bm.bandwidth_hz);
|
||||
}
|
||||
}
|
||||
if (typeof applyLocalTunedFrequency === "function") {
|
||||
// Set optimistic guard before applying so SSE cannot snap back.
|
||||
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||
++_freqOptimisticSeq;
|
||||
_freqOptimisticHz = bm.freq_hz;
|
||||
}
|
||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||
applyLocalTunedFrequency(bm.freq_hz, true);
|
||||
}
|
||||
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
|
||||
scheduleSpectrumDraw();
|
||||
}
|
||||
|
||||
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||
const tunePromise = (async () => {
|
||||
if (typeof vchanTakeSchedulerControl === "function") {
|
||||
await vchanTakeSchedulerControl();
|
||||
}
|
||||
|
||||
const onVirtual = typeof vchanInterceptMode === "function"
|
||||
&& await vchanInterceptMode(bm.mode);
|
||||
if (!onVirtual) {
|
||||
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
}
|
||||
|
||||
if (bm.bandwidth_hz) {
|
||||
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
|
||||
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
|
||||
if (!bwHandledByVchan) {
|
||||
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
||||
}
|
||||
}
|
||||
|
||||
// setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
||||
// when on a virtual channel, so this call works correctly in both cases.
|
||||
// It also does its own optimistic update (applyLocalTunedFrequency) but
|
||||
// that's a no-op since we already set the same value above.
|
||||
if (typeof setRigFrequency === "function") {
|
||||
await setRigFrequency(bm.freq_hz);
|
||||
} else {
|
||||
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||
}
|
||||
})();
|
||||
// Decoder toggles — fire-and-forget.
|
||||
// - Decoders incompatible with the new mode are always turned off
|
||||
// (even when the bookmark has no explicit decoder selection).
|
||||
// - For compatible decoders, if the bookmark specifies a set, the
|
||||
// toggles are driven to match that set; otherwise they're left
|
||||
// alone.
|
||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||
const modeUp = (bm.mode || "").toUpperCase();
|
||||
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
|
||||
d.activation === "toggle"
|
||||
);
|
||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||
let statusUrl = "/status";
|
||||
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
|
||||
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
|
||||
}
|
||||
const statusResp = await fetch(statusUrl);
|
||||
if (!statusResp.ok) return;
|
||||
const st = await statusResp.json();
|
||||
const toggles = [];
|
||||
for (const d of allToggleDecoders) {
|
||||
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||
const currentlyOn = !!st[statusKey];
|
||||
const compatible = Array.isArray(d.active_modes)
|
||||
&& d.active_modes.includes(modeUp);
|
||||
let wanted;
|
||||
if (!compatible) {
|
||||
// Always disable decoders that don't apply to the new mode.
|
||||
wanted = false;
|
||||
} else if (hasDecoders) {
|
||||
wanted = bm.decoders.includes(d.id);
|
||||
} else {
|
||||
// Mode-compatible and no bookmark selection: leave as-is.
|
||||
wanted = currentlyOn;
|
||||
}
|
||||
if (wanted !== currentlyOn) {
|
||||
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||
}
|
||||
}
|
||||
if (toggles.length) await Promise.all(toggles);
|
||||
})() : Promise.resolve();
|
||||
// Don't await — let the network calls settle in the background.
|
||||
// Errors are logged but don't block the UI.
|
||||
Promise.all([tunePromise, decoderPromise]).catch(
|
||||
(err) => console.error("Bookmark apply background error:", err)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to apply bookmark:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function bmUpdateSelectionUi() {
|
||||
const count = bmSelected.size;
|
||||
const canCtrl = bmCanControl();
|
||||
const visible = count > 0 && canCtrl;
|
||||
const btn = document.getElementById("bm-del-selected-btn");
|
||||
const countEl = document.getElementById("bm-del-selected-count");
|
||||
if (btn) btn.style.display = visible ? "" : "none";
|
||||
if (countEl) countEl.textContent = count;
|
||||
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
||||
const moveCountEl = document.getElementById("bm-move-selected-count");
|
||||
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||
if (moveCountEl) moveCountEl.textContent = count;
|
||||
if (visible) bmPopulateMoveTarget();
|
||||
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||
if (selectAllBtn && bmCanControl()) {
|
||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||
}
|
||||
}
|
||||
|
||||
/** Populate the move-target dropdown with all scopes except the current one. */
|
||||
function bmPopulateMoveTarget() {
|
||||
const sel = document.getElementById("bm-move-target");
|
||||
if (!sel) return;
|
||||
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = "";
|
||||
if (bmScope !== "general") {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "general";
|
||||
opt.textContent = "General";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
rigIds.forEach((id) => {
|
||||
if (id === bmScope) return;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = displayNames[id] || id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||
sel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
async function bmMoveSelected() {
|
||||
const ids = Array.from(bmSelected);
|
||||
if (ids.length === 0) return;
|
||||
const target = document.getElementById("bm-move-target")?.value;
|
||||
if (!target) return;
|
||||
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
|
||||
try {
|
||||
// Group selected IDs by their owning scope (skip if already in target).
|
||||
const byScope = {};
|
||||
for (const id of ids) {
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm?.scope || bmScope;
|
||||
if (scope === target) continue;
|
||||
(byScope[scope] ||= []).push(id);
|
||||
}
|
||||
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
|
||||
fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: scopeIds, to: target }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||
));
|
||||
bmSelected.clear();
|
||||
bmUpdateSelectionUi();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to move bookmarks:", err);
|
||||
alert("Failed to move bookmarks: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function bmSyncSelectAllCheckbox() {
|
||||
const selectAll = document.getElementById("bm-select-all");
|
||||
if (!selectAll) return;
|
||||
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||
if (checkboxes.length === 0) {
|
||||
selectAll.checked = false;
|
||||
selectAll.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||
selectAll.checked = checkedCount === checkboxes.length;
|
||||
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||
}
|
||||
|
||||
async function bmDeleteSelected() {
|
||||
const ids = Array.from(bmSelected);
|
||||
if (ids.length === 0) return;
|
||||
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
|
||||
try {
|
||||
// Group selected IDs by their owning scope.
|
||||
const byScope = {};
|
||||
for (const id of ids) {
|
||||
const bm = bmList.find((b) => b.id === id);
|
||||
const scope = bm?.scope || bmScope;
|
||||
(byScope[scope] ||= []).push(id);
|
||||
}
|
||||
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
|
||||
fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: scopeIds }),
|
||||
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||
));
|
||||
bmSelected.clear();
|
||||
bmUpdateSelectionUi();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmarks:", err);
|
||||
alert("Failed to delete bookmarks: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Populate the scope picker with "General" + one option per rig. */
|
||||
function bmPopulateScopePicker() {
|
||||
const picker = document.getElementById("bm-scope-picker");
|
||||
if (!picker) return;
|
||||
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||
// Preserve current selection if still valid.
|
||||
const prev = picker.value;
|
||||
while (picker.options.length > 1) picker.remove(1);
|
||||
rigIds.forEach((id) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = displayNames[id] || id;
|
||||
picker.appendChild(opt);
|
||||
});
|
||||
if (prev && (prev === "general" || rigIds.includes(prev))) {
|
||||
picker.value = prev;
|
||||
} else {
|
||||
picker.value = "general";
|
||||
}
|
||||
bmScope = picker.value;
|
||||
}
|
||||
|
||||
// --- Event wiring ---
|
||||
(function initBookmarks() {
|
||||
// Set initial button visibility (auth may already be resolved by the time
|
||||
// scripts run if auth is disabled; otherwise bmFetch() will sync it).
|
||||
bmSyncAccess();
|
||||
|
||||
// Build decoder checkboxes from registry. The registry is fetched async
|
||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||
bmBuildDecoderCheckboxes();
|
||||
if (typeof window.onDecoderRegistryReady === "function") {
|
||||
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||
}
|
||||
|
||||
// Scope picker
|
||||
bmPopulateScopePicker();
|
||||
const scopePicker = document.getElementById("bm-scope-picker");
|
||||
if (scopePicker) {
|
||||
scopePicker.addEventListener("change", (e) => {
|
||||
bmScope = e.target.value;
|
||||
bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh list and sync access when the Bookmarks tab is activated
|
||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
||||
if (!btn) return;
|
||||
bmFetch(document.getElementById("bm-category-filter").value);
|
||||
});
|
||||
|
||||
// Add Bookmark button — open form and prefill from current rig state
|
||||
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
||||
bmOpenForm(null);
|
||||
bmPrefillFromStatus();
|
||||
});
|
||||
|
||||
// Category filter dropdown
|
||||
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
||||
bmFetch(e.target.value);
|
||||
});
|
||||
|
||||
// Mode filter dropdown (client-side, no re-fetch)
|
||||
document.getElementById("bm-mode-filter").addEventListener("change", () => {
|
||||
bmApplyFilters();
|
||||
});
|
||||
|
||||
// Text search filter (client-side, no re-fetch)
|
||||
document.getElementById("bm-text-filter").addEventListener("input", () => {
|
||||
bmApplyFilters();
|
||||
});
|
||||
|
||||
document.getElementById("bm-page-prev").addEventListener("click", () => {
|
||||
bmChangePage(-1);
|
||||
});
|
||||
|
||||
document.getElementById("bm-page-next").addEventListener("click", () => {
|
||||
bmChangePage(1);
|
||||
});
|
||||
|
||||
// Form submit
|
||||
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
||||
|
||||
// Form cancel
|
||||
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||
|
||||
const formWrap = document.getElementById("bm-form-wrap");
|
||||
if (formWrap) {
|
||||
formWrap.addEventListener("click", (e) => {
|
||||
if (e.target === formWrap) bmCloseForm();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
|
||||
bmCloseForm();
|
||||
}
|
||||
});
|
||||
|
||||
// Select-all checkbox
|
||||
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
||||
const checked = e.target.checked;
|
||||
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||
cb.checked = checked;
|
||||
if (checked) bmSelected.add(cb.dataset.bmId);
|
||||
else bmSelected.delete(cb.dataset.bmId);
|
||||
});
|
||||
bmUpdateSelectionUi();
|
||||
});
|
||||
|
||||
// Select All (across all pages) button
|
||||
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
|
||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||
if (allSelected) {
|
||||
bmSelected.clear();
|
||||
} else {
|
||||
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||
}
|
||||
// Sync visible page checkboxes
|
||||
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||
cb.checked = bmSelected.has(cb.dataset.bmId);
|
||||
});
|
||||
bmSyncSelectAllCheckbox();
|
||||
bmUpdateSelectionUi();
|
||||
});
|
||||
|
||||
// Delete Selected button
|
||||
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
||||
bmDeleteSelected();
|
||||
});
|
||||
|
||||
// Move Selected button
|
||||
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
||||
bmMoveSelected();
|
||||
});
|
||||
|
||||
// Table action buttons and row checkboxes (event delegation)
|
||||
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||
const checkbox = e.target.closest(".bm-row-sel");
|
||||
if (checkbox) {
|
||||
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
||||
else bmSelected.delete(checkbox.dataset.bmId);
|
||||
bmSyncSelectAllCheckbox();
|
||||
bmUpdateSelectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||
const editBtn = e.target.closest(".bm-edit-btn");
|
||||
const delBtn = e.target.closest(".bm-del-btn");
|
||||
|
||||
if (tuneBtn) {
|
||||
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||
if (bm) await bmApply(bm);
|
||||
} else if (editBtn) {
|
||||
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||
if (bm) bmOpenForm(bm);
|
||||
} else if (delBtn) {
|
||||
await bmDelete(delBtn.dataset.bmId);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load bookmarks so spectrum markers are visible immediately.
|
||||
bmFetch("");
|
||||
})();
|
||||
@@ -0,0 +1,451 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||
const cwStatusEl = document.getElementById("cw-status");
|
||||
const cwOutputEl = document.getElementById("cw-output");
|
||||
const cwAutoInput = document.getElementById("cw-auto");
|
||||
const cwWpmInput = document.getElementById("cw-wpm");
|
||||
const cwToneInput = document.getElementById("cw-tone");
|
||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
||||
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
||||
: null;
|
||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||
const CW_MAX_LINES = 200;
|
||||
const CW_TONE_MIN_HZ = 100;
|
||||
const CW_TONE_MAX_HZ = 10_000;
|
||||
const CW_WPM_MIN = 5;
|
||||
const CW_WPM_MAX = 40;
|
||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const CW_BAR_LINE_GAP_MS = 5000;
|
||||
let cwLastAppendTime = 0;
|
||||
let cwTonePickerRaf = null;
|
||||
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
||||
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
||||
let cwBarDismissedAtMs = 0;
|
||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||
// acknowledged). While set, server-state updates must not override the
|
||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||
// does not immediately undo the user's choice.
|
||||
let cwAutoLocalOverride = null;
|
||||
|
||||
function applyCwAutoUi(enabled) {
|
||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||
if (cwWpmInput) {
|
||||
cwWpmInput.disabled = enabled;
|
||||
cwWpmInput.readOnly = enabled;
|
||||
}
|
||||
if (cwToneInput) {
|
||||
cwToneInput.disabled = enabled;
|
||||
cwToneInput.readOnly = enabled;
|
||||
}
|
||||
if (cwTonePickerEl) {
|
||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||
}
|
||||
}
|
||||
window.applyCwAutoUi = applyCwAutoUi;
|
||||
|
||||
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
||||
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
||||
window.applyCwAutoUiFromServer = function(enabled) {
|
||||
if (cwAutoLocalOverride !== null) return;
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
|
||||
function cwBarFlushCurrentLine() {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||
}
|
||||
cwBarCurrentLine = null;
|
||||
}
|
||||
|
||||
function updateCwBar() {
|
||||
if (!cwBarOverlay) return;
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isCw = mode === "CW" || mode === "CWR";
|
||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||
// Prepend the in-progress line so characters appear immediately
|
||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
cwBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
let html =
|
||||
'<div class="aprs-bar-header">' +
|
||||
'<span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span>' +
|
||||
'<span class="aprs-bar-actions">' +
|
||||
'<span class="aprs-bar-window">Last 15 minutes</span>' +
|
||||
'<span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0"' +
|
||||
' onclick="window.clearCwBar()"' +
|
||||
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearCwBar();}"' +
|
||||
' aria-label="Clear CW overlay">Clear</span></span>' +
|
||||
'<button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button>' +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
for (const line of liveLines.slice(0, 8)) {
|
||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||
const meta = [
|
||||
line.wpm ? `${line.wpm} WPM` : null,
|
||||
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
||||
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
||||
`</div></div>`;
|
||||
}
|
||||
cwBarOverlay.innerHTML = html;
|
||||
cwBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateCwBar = updateCwBar;
|
||||
window.clearCwBar = function() {
|
||||
window.resetCwHistoryView();
|
||||
};
|
||||
window.closeCwBar = function() {
|
||||
cwBarDismissedAtMs = Date.now();
|
||||
if (cwBarOverlay) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
cwBarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
function clampCwWpm(wpm) {
|
||||
const numeric = Number(wpm);
|
||||
if (!Number.isFinite(numeric)) return 15;
|
||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||
}
|
||||
|
||||
function clampCwTone(tone) {
|
||||
const numeric = Number(tone);
|
||||
if (!Number.isFinite(numeric)) return 700;
|
||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||
}
|
||||
|
||||
function currentCwToneRange() {
|
||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||
return null;
|
||||
}
|
||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const lowerSideband = mode === "CWR";
|
||||
const upperSideband = mode === "CW";
|
||||
if (!lowerSideband && !upperSideband) return null;
|
||||
|
||||
const toneMinHz = CW_TONE_MIN_HZ;
|
||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||
if (toneMaxHz < toneMinHz) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tunedHz,
|
||||
bandwidthHz,
|
||||
toneMinHz,
|
||||
toneMaxHz,
|
||||
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||
lowerSideband,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function cwToneToRfHz(range, toneHz) {
|
||||
if (!range) return NaN;
|
||||
return range.lowerSideband
|
||||
? range.tunedHz - toneHz
|
||||
: range.tunedHz + toneHz;
|
||||
}
|
||||
|
||||
function toneClampForRange(tone, range) {
|
||||
const clamped = clampCwTone(tone);
|
||||
if (!range) return clamped;
|
||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||
}
|
||||
|
||||
function ensureCwToneCanvasResolution() {
|
||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||
const rect = cwToneCanvas.getBoundingClientRect();
|
||||
const cssWidth = Math.round(rect.width);
|
||||
const cssHeight = Math.round(rect.height);
|
||||
if (cssWidth < 8 || cssHeight < 8) {
|
||||
return false;
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||
}
|
||||
|
||||
function drawCwTonePicker() {
|
||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||
ensureCwToneCanvasResolution();
|
||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||
const width = cwToneCanvas.width;
|
||||
const height = cwToneCanvas.height;
|
||||
cwToneGl.clear([0, 0, 0, 0]);
|
||||
|
||||
const range = currentCwToneRange();
|
||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
||||
if (cwToneRangeEl) {
|
||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||
if (mode !== "CW" && mode !== "CWR") {
|
||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||
}
|
||||
}
|
||||
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cwToneRangeEl) {
|
||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
||||
}
|
||||
|
||||
const bins = window.lastSpectrumData.bins;
|
||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
||||
const maxIdx = Math.max(1, bins.length - 1);
|
||||
const fullLoHz = centerHz - sampleRate / 2;
|
||||
const tones = new Array(width).fill(-140);
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||
const rfHz = cwToneToRfHz(range, toneHz);
|
||||
const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx))));
|
||||
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
||||
tones[x] = power;
|
||||
}
|
||||
|
||||
const smoothed = new Array(width).fill(-140);
|
||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||
if (i < 0 || i >= width) continue;
|
||||
sum += tones[i];
|
||||
count += 1;
|
||||
}
|
||||
smoothed[x] = count > 0 ? sum / count : tones[x];
|
||||
}
|
||||
|
||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||
const yForDb = (db) => {
|
||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||
return Math.round((1 - n) * (height - 1));
|
||||
};
|
||||
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||
const parseColor = typeof window.trxParseCssColor === "function"
|
||||
? window.trxParseCssColor
|
||||
: null;
|
||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||
|
||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||
|
||||
const hGridCount = 4;
|
||||
const gridSegments = [];
|
||||
for (let i = 1; i <= hGridCount; i += 1) {
|
||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||
gridSegments.push(0, y, width, y);
|
||||
}
|
||||
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||
|
||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||
const tickSegments = [];
|
||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||
tickSegments.push(x, 0, x, height);
|
||||
}
|
||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||
|
||||
const linePoints = [];
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
linePoints.push(x, yForDb(smoothed[x]));
|
||||
}
|
||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||
|
||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||
|
||||
if (cwAutoInput?.checked) {
|
||||
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||
}
|
||||
}
|
||||
|
||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||
const range = currentCwToneRange();
|
||||
const clamped = toneClampForRange(tone, range);
|
||||
if (cwToneInput && syncInput) {
|
||||
cwToneInput.value = clamped;
|
||||
}
|
||||
try {
|
||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
} catch (e) {
|
||||
console.error("CW tone set failed", e);
|
||||
}
|
||||
drawCwTonePicker();
|
||||
}
|
||||
|
||||
if (cwAutoInput) {
|
||||
cwAutoInput.addEventListener("change", async () => {
|
||||
const enabled = cwAutoInput.checked;
|
||||
cwAutoLocalOverride = enabled;
|
||||
applyCwAutoUi(enabled);
|
||||
try {
|
||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
drawCwTonePicker();
|
||||
} catch (e) {
|
||||
console.error("CW auto toggle failed", e);
|
||||
} finally {
|
||||
cwAutoLocalOverride = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cwWpmInput) {
|
||||
cwWpmInput.addEventListener("change", async () => {
|
||||
if (cwAutoInput && cwAutoInput.checked) return;
|
||||
const wpm = clampCwWpm(cwWpmInput.value);
|
||||
cwWpmInput.value = wpm;
|
||||
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||
catch (e) { console.error("CW WPM set failed", e); }
|
||||
});
|
||||
}
|
||||
|
||||
if (cwToneInput) {
|
||||
cwToneInput.addEventListener("change", async () => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
await setCwTone(cwToneInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (cwToneCanvas) {
|
||||
cwToneCanvas.addEventListener("click", async (event) => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
const rect = cwToneCanvas.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const range = currentCwToneRange();
|
||||
if (!range) return;
|
||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||
await setCwTone(tone);
|
||||
});
|
||||
}
|
||||
|
||||
window.resetCwHistoryView = function() {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLine = null;
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_cw_decode");
|
||||
window.resetCwHistoryView();
|
||||
} catch (e) {
|
||||
console.error("CW history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Server-side CW decode handler ---
|
||||
window.onServerCw = function(evt) {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
// Append decoded text to output
|
||||
const now = Date.now();
|
||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||
const line = document.createElement("div");
|
||||
line.className = "cw-line";
|
||||
cwOutputEl.appendChild(line);
|
||||
}
|
||||
cwLastAppendTime = now;
|
||||
const lastLine = cwOutputEl.lastElementChild;
|
||||
if (lastLine) {
|
||||
lastLine.textContent += evt.text;
|
||||
}
|
||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
||||
}
|
||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||
}
|
||||
// Bar history accumulation (regardless of pause state)
|
||||
if (evt.text) {
|
||||
const now = Date.now();
|
||||
if (evt.text === "\n") {
|
||||
cwBarFlushCurrentLine();
|
||||
} else {
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||
}
|
||||
cwBarCurrentLine.text += evt.text;
|
||||
cwBarCurrentLine.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator) {
|
||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||
}
|
||||
if (!cwAutoInput || cwAutoInput.checked) {
|
||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
||||
}
|
||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
||||
}
|
||||
}
|
||||
if (cwTonePickerRaf != null) return;
|
||||
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||
cwTonePickerRaf = null;
|
||||
drawCwTonePicker();
|
||||
});
|
||||
};
|
||||
|
||||
window.restoreCwHistory = function(events) {
|
||||
if (!Array.isArray(events) || events.length === 0) return;
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
for (const evt of events) {
|
||||
window.onServerCw(evt);
|
||||
}
|
||||
};
|
||||
|
||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||
ensureCwToneCanvasResolution();
|
||||
drawCwTonePicker();
|
||||
};
|
||||
window.addEventListener("resize", () => {
|
||||
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||
});
|
||||
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||
updateCwBar();
|
||||
ensureCwToneCanvasResolution();
|
||||
drawCwTonePicker();
|
||||
@@ -0,0 +1,207 @@
|
||||
// --- FT2 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
function ft8RenderMessageFt2(message) {
|
||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
const ft2Status = document.getElementById("ft2-status");
|
||||
const ft2PeriodEl = document.getElementById("ft2-period");
|
||||
const ft2MessagesEl = document.getElementById("ft2-messages");
|
||||
const ft2FilterInput = document.getElementById("ft2-filter");
|
||||
const FT2_PERIOD_MS = 3750;
|
||||
const FT2_MAX_DOM_ROWS = 200;
|
||||
let ft2FilterText = "";
|
||||
let ft2MessageHistory = [];
|
||||
|
||||
function currentFt2HistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneFt2MessageHistory() {
|
||||
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
|
||||
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleFt2Ui(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
|
||||
|
||||
function normalizeFt2DisplayFreqHz(freqHz) {
|
||||
const rawHz = Number(freqHz);
|
||||
if (!Number.isFinite(rawHz)) return null;
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||
return baseHz + rawHz;
|
||||
}
|
||||
return rawHz;
|
||||
}
|
||||
|
||||
function updateFt2PeriodTimer() {
|
||||
if (!ft2PeriodEl) return;
|
||||
const nowMs = Date.now();
|
||||
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
|
||||
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
updateFt2PeriodTimer();
|
||||
setInterval(updateFt2PeriodTimer, 250);
|
||||
|
||||
function renderFt2Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
const rawMessage = (msg.message || "").toString();
|
||||
row.dataset.message = rawMessage.toUpperCase();
|
||||
row.dataset.decoder = "ft2";
|
||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||
const renderedMessage = ft8RenderMessageFt2(rawMessage);
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFt2History() {
|
||||
pruneFt2MessageHistory();
|
||||
if (!ft2MessagesEl) return;
|
||||
const filter = ft2FilterText;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let rendered = 0;
|
||||
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
|
||||
const msg = ft2MessageHistory[i];
|
||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||
fragment.appendChild(renderFt2Row(msg));
|
||||
rendered++;
|
||||
}
|
||||
ft2MessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addFt2Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft2MessageHistory.unshift(msg);
|
||||
pruneFt2MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft2");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt2HistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerFt2Message(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((d) => d.grid)
|
||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||
return {
|
||||
raw, grids, station, rfHz, locatorDetails,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||
message: msg.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerFt2Batch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerFt2Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
ft2MessageHistory = normalized.concat(ft2MessageHistory);
|
||||
pruneFt2MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft2");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt2HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
|
||||
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
|
||||
|
||||
window.resetFt2HistoryView = function() {
|
||||
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
||||
ft2MessageHistory = [];
|
||||
window.updateFt8Bar?.();
|
||||
renderFt2History();
|
||||
};
|
||||
|
||||
function buildFt2BarFrames() {
|
||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
||||
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||
if (messages.length === 0) {
|
||||
return { count: 0, newestTsMs: 0, html: "" };
|
||||
}
|
||||
let html = "";
|
||||
for (const msg of messages) {
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = ft8RenderMessageFt2((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
return { count: messages.length, newestTsMs, html };
|
||||
}
|
||||
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
|
||||
|
||||
if (ft2FilterInput) {
|
||||
ft2FilterInput.addEventListener("input", () => {
|
||||
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
||||
renderFt2History();
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ft2_decode");
|
||||
window.resetFt2HistoryView();
|
||||
} catch (e) { console.error("FT2 history clear failed", e); }
|
||||
});
|
||||
|
||||
window.onServerFt2 = function(msg) {
|
||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||
const next = normalizeServerFt2Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
addFt2Message(next.history);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
// --- FT4 Decoder Plugin (server-side decode) ---
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
function ft8RenderMessage(message) {
|
||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
const ft4Status = document.getElementById("ft4-status");
|
||||
const ft4PeriodEl = document.getElementById("ft4-period");
|
||||
const ft4MessagesEl = document.getElementById("ft4-messages");
|
||||
const ft4FilterInput = document.getElementById("ft4-filter");
|
||||
const FT4_PERIOD_MS = 7500;
|
||||
const FT4_MAX_DOM_ROWS = 200;
|
||||
let ft4FilterText = "";
|
||||
let ft4MessageHistory = [];
|
||||
|
||||
function currentFt4HistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneFt4MessageHistory() {
|
||||
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
|
||||
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleFt4Ui(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
|
||||
|
||||
function normalizeFt4DisplayFreqHz(freqHz) {
|
||||
const rawHz = Number(freqHz);
|
||||
if (!Number.isFinite(rawHz)) return null;
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||
return baseHz + rawHz;
|
||||
}
|
||||
return rawHz;
|
||||
}
|
||||
|
||||
function updateFt4PeriodTimer() {
|
||||
if (!ft4PeriodEl) return;
|
||||
const nowMs = Date.now();
|
||||
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
|
||||
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
updateFt4PeriodTimer();
|
||||
setInterval(updateFt4PeriodTimer, 250);
|
||||
|
||||
function renderFt4Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
const rawMessage = (msg.message || "").toString();
|
||||
row.dataset.message = rawMessage.toUpperCase();
|
||||
row.dataset.decoder = "ft4";
|
||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||
const renderedMessage = ft8RenderMessage(rawMessage);
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFt4History() {
|
||||
pruneFt4MessageHistory();
|
||||
if (!ft4MessagesEl) return;
|
||||
const filter = ft4FilterText;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let rendered = 0;
|
||||
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
|
||||
const msg = ft4MessageHistory[i];
|
||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||
fragment.appendChild(renderFt4Row(msg));
|
||||
rendered++;
|
||||
}
|
||||
ft4MessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addFt4Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft4MessageHistory.unshift(msg);
|
||||
pruneFt4MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft4");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt4HistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerFt4Message(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((d) => d.grid)
|
||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||
return {
|
||||
raw, grids, station, rfHz, locatorDetails,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||
message: msg.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerFt4Batch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerFt4Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
ft4MessageHistory = normalized.concat(ft4MessageHistory);
|
||||
pruneFt4MessageHistory();
|
||||
window.setFt8FamilyBarDecoder?.("ft4");
|
||||
window.updateFt8Bar?.();
|
||||
scheduleFt4HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
|
||||
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
|
||||
|
||||
window.resetFt4HistoryView = function() {
|
||||
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
|
||||
ft4MessageHistory = [];
|
||||
window.updateFt8Bar?.();
|
||||
renderFt4History();
|
||||
};
|
||||
|
||||
function buildFt4BarFrames() {
|
||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
||||
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||
if (messages.length === 0) {
|
||||
return { count: 0, newestTsMs: 0, html: "" };
|
||||
}
|
||||
let html = "";
|
||||
for (const msg of messages) {
|
||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = ft8RenderMessage((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
return { count: messages.length, newestTsMs, html };
|
||||
}
|
||||
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
|
||||
|
||||
if (ft4FilterInput) {
|
||||
ft4FilterInput.addEventListener("input", () => {
|
||||
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
|
||||
renderFt4History();
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ft4_decode");
|
||||
window.resetFt4HistoryView();
|
||||
} catch (e) { console.error("FT4 history clear failed", e); }
|
||||
});
|
||||
|
||||
window.onServerFt4 = function(msg) {
|
||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||
const next = normalizeServerFt4Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||
}
|
||||
addFt4Message(next.history);
|
||||
};
|
||||
@@ -0,0 +1,486 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- FT8 Decoder Plugin (server-side decode) ---
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
const ft8PeriodEl = document.getElementById("ft8-period");
|
||||
const ft8MessagesEl = document.getElementById("ft8-messages");
|
||||
const ft8FilterInput = document.getElementById("ft8-filter");
|
||||
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
|
||||
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
const FT8_PERIOD_SECONDS = 15;
|
||||
const FT8_MAX_DOM_ROWS = 200;
|
||||
const FT8_BAR_DECODER_LABELS = {
|
||||
ft8: "FT8",
|
||||
ft4: "FT4",
|
||||
ft2: "FT2",
|
||||
};
|
||||
let ft8FilterText = "";
|
||||
let ft8MessageHistory = [];
|
||||
let ft8BarActiveDecoder = "ft8";
|
||||
const ft8BarBuilders = {};
|
||||
const ft8BarDismissedAtMsByDecoder = {
|
||||
ft8: 0,
|
||||
ft4: 0,
|
||||
ft2: 0,
|
||||
};
|
||||
|
||||
function currentFt8HistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneFt8MessageHistory() {
|
||||
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
|
||||
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleFt8Ui(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleFt8HistoryRender() {
|
||||
scheduleFt8Ui("ft8-history", () => renderFt8History());
|
||||
}
|
||||
|
||||
function scheduleFt8BarUpdate() {
|
||||
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
|
||||
}
|
||||
|
||||
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
|
||||
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
|
||||
ft8BarBuilders[decoder] = builder;
|
||||
};
|
||||
|
||||
window.setFt8FamilyBarDecoder = function(decoder) {
|
||||
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
|
||||
ft8BarActiveDecoder = decoder;
|
||||
scheduleFt8BarUpdate();
|
||||
};
|
||||
|
||||
function normalizeFt8DisplayFreqHz(freqHz) {
|
||||
const rawHz = Number(freqHz);
|
||||
if (!Number.isFinite(rawHz)) return null;
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||
return baseHz + rawHz;
|
||||
}
|
||||
return rawHz;
|
||||
}
|
||||
|
||||
function fmtTime(tsMs) {
|
||||
if (!tsMs) return "--:--:--";
|
||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function updateFt8PeriodTimer() {
|
||||
if (!ft8PeriodEl) return;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
|
||||
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
updateFt8PeriodTimer();
|
||||
setInterval(updateFt8PeriodTimer, 500);
|
||||
|
||||
function renderFt8Row(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
const rawMessage = (msg.message || "").toString();
|
||||
row.dataset.message = rawMessage.toUpperCase();
|
||||
row.dataset.decoder = "ft8";
|
||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||
const renderedMessage = renderFt8Message(rawMessage);
|
||||
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||
applyFt8FilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFt8History() {
|
||||
pruneFt8MessageHistory();
|
||||
if (!ft8MessagesEl) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
|
||||
}
|
||||
ft8MessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addFt8Message(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
ft8MessageHistory.unshift(msg);
|
||||
pruneFt8MessageHistory();
|
||||
ft8BarActiveDecoder = "ft8";
|
||||
scheduleFt8BarUpdate();
|
||||
scheduleFt8HistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerFt8Message(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
||||
const grids = locatorDetails.length > 0
|
||||
? locatorDetails.map((detail) => detail.grid)
|
||||
: ft8ExtractAllGrids(raw);
|
||||
const station = ft8ExtractLikelyCallsign(raw);
|
||||
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
return {
|
||||
raw,
|
||||
grids,
|
||||
station,
|
||||
rfHz,
|
||||
locatorDetails,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
dt_s: msg.dt_s,
|
||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||
message: msg.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerFt8Batch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
ft8Status.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerFt8Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
locator_details: next.locatorDetails,
|
||||
});
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
ft8MessageHistory = normalized.concat(ft8MessageHistory);
|
||||
pruneFt8MessageHistory();
|
||||
ft8BarActiveDecoder = "ft8";
|
||||
scheduleFt8BarUpdate();
|
||||
scheduleFt8HistoryRender();
|
||||
};
|
||||
|
||||
window.restoreFt8History = function(messages) {
|
||||
window.onServerFt8Batch(messages);
|
||||
};
|
||||
|
||||
window.pruneFt8HistoryView = function() {
|
||||
pruneFt8MessageHistory();
|
||||
updateFt8Bar();
|
||||
renderFt8History();
|
||||
};
|
||||
|
||||
function ft8BarRfText(msg) {
|
||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||
if (!Number.isFinite(displayFreqHz)) return null;
|
||||
return `${displayFreqHz.toFixed(0)} Hz`;
|
||||
}
|
||||
|
||||
function buildFt8BarFrames() {
|
||||
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
|
||||
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
|
||||
if (messages.length === 0) {
|
||||
return { count: 0, newestTsMs: 0, html: "" };
|
||||
}
|
||||
let html = "";
|
||||
for (const msg of messages) {
|
||||
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||
const rf = ft8BarRfText(msg);
|
||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||
const text = ft8EscapeHtml((msg.message || "").toString());
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||
}
|
||||
return { count: messages.length, newestTsMs, html };
|
||||
}
|
||||
|
||||
function updateFt8Bar() {
|
||||
if (!ft8BarOverlay) return;
|
||||
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
|
||||
const decoder = ft8BarActiveDecoder;
|
||||
const builder = ft8BarBuilders[decoder];
|
||||
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
|
||||
const result = typeof builder === "function" ? builder() : null;
|
||||
const newestTsMs = Number(result?.newestTsMs) || 0;
|
||||
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
|
||||
ft8BarOverlay.style.display = "none";
|
||||
ft8BarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
||||
ft8BarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateFt8Bar = updateFt8Bar;
|
||||
window.clearFt8Bar = function() {
|
||||
const decoder = ft8BarActiveDecoder;
|
||||
if (decoder === "ft4") {
|
||||
window.resetFt4HistoryView?.();
|
||||
return;
|
||||
}
|
||||
if (decoder === "ft2") {
|
||||
window.resetFt2HistoryView?.();
|
||||
return;
|
||||
}
|
||||
window.resetFt8HistoryView?.();
|
||||
};
|
||||
window.closeFt8Bar = function() {
|
||||
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
|
||||
if (ft8BarOverlay) {
|
||||
ft8BarOverlay.style.display = "none";
|
||||
ft8BarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
|
||||
|
||||
function renderFt8Message(message) {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
const ch = message[i];
|
||||
if (ft8IsAlphaNum(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (ft8IsMaidenheadGridToken(grid)) {
|
||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||
} else {
|
||||
out += ft8EscapeHtml(token);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
out += ft8EscapeHtml(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ft8TokenizeMessage(message) {
|
||||
return String(message || "")
|
||||
.toUpperCase()
|
||||
.split(/[^A-Z0-9/]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ft8ExtractAllGrids(message) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
if (ft8IsAlphaNum(message[i])) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
||||
seen.add(grid);
|
||||
out.push(grid);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ft8ExtractLocatorDetails(message) {
|
||||
const tokens = ft8TokenizeMessage(message);
|
||||
const grids = ft8ExtractAllGrids(String(message || ""));
|
||||
if (tokens.length === 0 || grids.length === 0) return [];
|
||||
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
||||
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
||||
const callsigns = [];
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
||||
}
|
||||
|
||||
let source = null;
|
||||
let target = null;
|
||||
const head = tokens[0];
|
||||
if (callsigns.length > 0) {
|
||||
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
||||
source = callsigns[0];
|
||||
} else if (callsigns.length >= 2) {
|
||||
target = callsigns[0];
|
||||
source = callsigns[1];
|
||||
} else {
|
||||
source = callsigns[0];
|
||||
}
|
||||
}
|
||||
|
||||
return grids.map((grid) => ({
|
||||
grid,
|
||||
station: source || null,
|
||||
source: source || null,
|
||||
target: target || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function ft8ExtractLikelyCallsign(message) {
|
||||
const locatorDetails = ft8ExtractLocatorDetails(message);
|
||||
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
||||
return locatorDetails[0].station;
|
||||
}
|
||||
const tokens = ft8TokenizeMessage(message);
|
||||
for (const token of tokens) {
|
||||
if (ft8IsLikelyCallsignToken(token)) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ft8IsLikelyCallsignToken(token) {
|
||||
if (!token) return false;
|
||||
if (token.length < 3 || token.length > 12) return false;
|
||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
||||
if (ft8IsMaidenheadGridToken(token)) return false;
|
||||
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||
}
|
||||
|
||||
function ft8IsFarewellToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||
}
|
||||
|
||||
function ft8IsMaidenheadGridToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
||||
}
|
||||
|
||||
function ft8EscapeHtml(input) {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
function ft8IsAlphaNum(ch) {
|
||||
return /[A-Za-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
function activateFt8HistoryLocator(targetEl) {
|
||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||
if (!locatorEl) return false;
|
||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||
if (!grid) return false;
|
||||
if (typeof window.navigateToMapLocator === "function") {
|
||||
window.navigateToMapLocator(grid, "ft8");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFt8FilterToRow(row) {
|
||||
if (!ft8FilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.message || "";
|
||||
row.style.display = message.includes(ft8FilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyFt8FilterToAll() {
|
||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||
rows.forEach((row) => applyFt8FilterToRow(row));
|
||||
}
|
||||
|
||||
function updateFt8RowRf(row) {
|
||||
const freqEl = row.querySelector(".ft8-freq");
|
||||
if (!freqEl) return;
|
||||
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
|
||||
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
|
||||
if (Number.isFinite(displayFreqHz)) {
|
||||
freqEl.textContent = displayFreqHz.toFixed(0);
|
||||
} else {
|
||||
freqEl.textContent = "--";
|
||||
}
|
||||
}
|
||||
|
||||
window.updateFt8RfDisplay = function() {
|
||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||
rows.forEach((row) => updateFt8RowRf(row));
|
||||
updateFt8Bar();
|
||||
};
|
||||
|
||||
window.resetFt8HistoryView = function() {
|
||||
ft8MessagesEl.innerHTML = "";
|
||||
ft8MessageHistory = [];
|
||||
updateFt8Bar();
|
||||
renderFt8History();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
|
||||
};
|
||||
|
||||
if (ft8FilterInput) {
|
||||
ft8FilterInput.addEventListener("input", () => {
|
||||
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
|
||||
renderFt8History();
|
||||
});
|
||||
}
|
||||
|
||||
if (ft8MessagesEl) {
|
||||
ft8MessagesEl.addEventListener("click", (event) => {
|
||||
if (!activateFt8HistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
ft8MessagesEl.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (!activateFt8HistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_ft8_decode");
|
||||
window.resetFt8HistoryView();
|
||||
} catch (e) {
|
||||
console.error("FT8 history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Server-side FT8 decode handler ---
|
||||
window.onServerFt8 = function(msg) {
|
||||
ft8Status.textContent = "Receiving";
|
||||
const next = normalizeServerFt8Message(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
locator_details: next.locatorDetails,
|
||||
});
|
||||
}
|
||||
addFt8Message(next.history);
|
||||
};
|
||||
@@ -0,0 +1,444 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||
let hfAprsFilterText = "";
|
||||
let hfAprsPacketHistory = [];
|
||||
let hfAprsOnlyPos = false;
|
||||
let hfAprsHideCrc = false;
|
||||
let hfAprsCollapseDup = false;
|
||||
let hfAprsTypeFilter = "all";
|
||||
|
||||
function currentHfAprsHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneHfAprsPacketHistory() {
|
||||
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleHfAprsHistoryRender() {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
|
||||
return;
|
||||
}
|
||||
renderHfAprsHistory();
|
||||
}
|
||||
|
||||
function hfAprsPacketCategory(pkt) {
|
||||
const type = String(pkt.type || "").toLowerCase();
|
||||
const info = String(pkt.info || "").toLowerCase();
|
||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function hfAprsCategoryLabel(category) {
|
||||
switch (category) {
|
||||
case "position": return "Position";
|
||||
case "message": return "Message";
|
||||
case "weather": return "Weather";
|
||||
case "telemetry": return "Telemetry";
|
||||
default: return "Other";
|
||||
}
|
||||
}
|
||||
|
||||
function hfAprsAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function hfAprsDistanceText(pkt) {
|
||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function hfAprsPacketSignature(pkt) {
|
||||
return [
|
||||
pkt.srcCall || "",
|
||||
pkt.destCall || "",
|
||||
pkt.path || "",
|
||||
pkt.info || "",
|
||||
pkt.type || "",
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function hfAprsHexBytes(bytes) {
|
||||
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||
}
|
||||
|
||||
function hfAprsFilterMatch(pkt) {
|
||||
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||
if (!hfAprsFilterText) return true;
|
||||
const haystack = [
|
||||
pkt.srcCall,
|
||||
pkt.destCall,
|
||||
pkt.path,
|
||||
pkt.info,
|
||||
pkt.type,
|
||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||
hfAprsPacketCategory(pkt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
return haystack.includes(hfAprsFilterText);
|
||||
}
|
||||
|
||||
function hfAprsVisiblePackets() {
|
||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||
return packets.filter(hfAprsFilterMatch);
|
||||
}
|
||||
|
||||
function collapseHfAprsDuplicates(packets) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pkt of packets) {
|
||||
const key = hfAprsPacketSignature(pkt);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(pkt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function updateHfAprsSummary() {
|
||||
const visible = hfAprsVisiblePackets();
|
||||
if (hfAprsTotalCountEl) {
|
||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||
}
|
||||
if (hfAprsVisibleCountEl) {
|
||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||
}
|
||||
if (hfAprsLatestSeenEl) {
|
||||
const latest = hfAprsPacketHistory[0];
|
||||
if (!latest) {
|
||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||
} else {
|
||||
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateHfAprsChipState() {
|
||||
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
||||
});
|
||||
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
||||
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||
}
|
||||
|
||||
function renderHfAprsInfo(pkt) {
|
||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||
if (bytes && bytes.length > 0) {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i];
|
||||
if (b >= 0x20 && b <= 0x7e) {
|
||||
const ch = String.fromCharCode(b);
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const str = pkt.info || "";
|
||||
let out = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
const ch = str[i];
|
||||
if (ch === "<") out += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
else out += ch;
|
||||
} else {
|
||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderHfAprsRow(pkt, isFresh) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "aprs-packet";
|
||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||
if (isFresh) row.classList.add("aprs-packet-new");
|
||||
|
||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const age = hfAprsAgeText(pkt._tsMs);
|
||||
const category = hfAprsPacketCategory(pkt);
|
||||
const categoryLabel = hfAprsCategoryLabel(category);
|
||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
||||
let symbolHtml = "";
|
||||
if (pkt.symbolTable && pkt.symbolCode) {
|
||||
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||
const col = code % 16;
|
||||
const row2 = Math.floor(code / 16);
|
||||
const bgX = -(col * 24);
|
||||
const bgY = -(row2 * 24);
|
||||
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||
}
|
||||
const posLink = pkt.lat != null && pkt.lon != null
|
||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
const distance = hfAprsDistanceText(pkt);
|
||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||
|
||||
row.innerHTML =
|
||||
`<div class="aprs-row-head">` +
|
||||
`<span class="aprs-time">${ts}</span>` +
|
||||
hfBadge +
|
||||
symbolHtml +
|
||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||
pathBadge +
|
||||
crcBadge +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-meta">` +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-detail">` +
|
||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
|
||||
(posLink ? `<span>${posLink}</span>` : "") +
|
||||
`</div>` +
|
||||
`<div class="aprs-row-actions">` +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||
`</div>` +
|
||||
`<details class="aprs-details">` +
|
||||
`<summary>Details</summary>` +
|
||||
`<div class="aprs-details-grid">` +
|
||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
|
||||
`</div>` +
|
||||
`</details>`;
|
||||
|
||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||
el.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
const raw = String(el.dataset.aprsMap || "");
|
||||
const [lat, lon] = raw.split(",").map(Number);
|
||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
showHint("Coordinates copied", 1200);
|
||||
}
|
||||
} catch (_e) {
|
||||
showHint("Copy failed", 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderHfAprsHistory() {
|
||||
pruneHfAprsPacketHistory();
|
||||
if (!hfAprsPacketsEl) {
|
||||
updateHfAprsSummary();
|
||||
updateHfAprsChipState();
|
||||
return;
|
||||
}
|
||||
const visible = hfAprsVisiblePackets();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
|
||||
}
|
||||
hfAprsPacketsEl.replaceChildren(fragment);
|
||||
updateHfAprsSummary();
|
||||
updateHfAprsChipState();
|
||||
}
|
||||
|
||||
window.resetHfAprsHistoryView = function() {
|
||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||
hfAprsPacketHistory = [];
|
||||
renderHfAprsHistory();
|
||||
};
|
||||
|
||||
window.pruneHfAprsHistoryView = function() {
|
||||
pruneHfAprsPacketHistory();
|
||||
renderHfAprsHistory();
|
||||
};
|
||||
|
||||
function addHfAprsPacket(pkt) {
|
||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||
pkt._tsMs = tsMs;
|
||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
|
||||
hfAprsPacketHistory.unshift(pkt);
|
||||
pruneHfAprsPacketHistory();
|
||||
|
||||
scheduleHfAprsHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerHfAprsPacket(pkt) {
|
||||
return {
|
||||
rig_id: pkt.rig_id || null,
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
srcCall: pkt.src_call,
|
||||
destCall: pkt.dest_call,
|
||||
path: pkt.path,
|
||||
info: pkt.info,
|
||||
info_bytes: pkt.info_bytes,
|
||||
type: pkt.packet_type,
|
||||
crcOk: pkt.crc_ok,
|
||||
ts_ms: pkt.ts_ms,
|
||||
lat: pkt.lat,
|
||||
lon: pkt.lon,
|
||||
symbolTable: pkt.symbol_table,
|
||||
symbolCode: pkt.symbol_code,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerHfAprsBatch = function(packets) {
|
||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const pkt of packets) {
|
||||
const next = normalizeServerHfAprsPacket(pkt);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
||||
pruneHfAprsPacketHistory();
|
||||
scheduleHfAprsHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreHfAprsHistory = function(packets) {
|
||||
window.onServerHfAprsBatch(packets);
|
||||
};
|
||||
|
||||
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 () => {
|
||||
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_hf_aprs_decode");
|
||||
window.resetHfAprsHistoryView();
|
||||
} catch (e) {
|
||||
console.error("HF APRS history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (hfAprsOnlyPosBtn) {
|
||||
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||
hfAprsOnlyPos = !hfAprsOnlyPos;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (hfAprsHideCrcBtn) {
|
||||
hfAprsHideCrcBtn.addEventListener("click", () => {
|
||||
hfAprsHideCrc = !hfAprsHideCrc;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (hfAprsCollapseDupBtn) {
|
||||
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
||||
hfAprsCollapseDup = !hfAprsCollapseDup;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
hfAprsTypeFilter = type;
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
});
|
||||
|
||||
if (hfAprsFilterInput) {
|
||||
hfAprsFilterInput.addEventListener("input", () => {
|
||||
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
||||
renderHfAprsHistory();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Server-side HF APRS decode handler ---
|
||||
window.onServerHfAprs = function(pkt) {
|
||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||
};
|
||||
|
||||
renderHfAprsHistory();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Satellite Pass Scheduling UI
|
||||
// Manages the satellite overlay section within the background decoding scheduler.
|
||||
// Communicates with scheduler.js via a thin window API for shared state access.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ── DOM references (cached once) ──────────────────────────────────
|
||||
const dom = {
|
||||
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||
body: document.getElementById("scheduler-sat-body"),
|
||||
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||
formTitle: document.getElementById("sch-sat-form-title"),
|
||||
form: document.getElementById("sch-sat-form"),
|
||||
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||
preset: document.getElementById("scheduler-sat-preset"),
|
||||
name: document.getElementById("scheduler-sat-name"),
|
||||
norad: document.getElementById("scheduler-sat-norad"),
|
||||
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||
priority: document.getElementById("scheduler-sat-priority"),
|
||||
centerHz: document.getElementById("scheduler-sat-center-hz"),
|
||||
};
|
||||
|
||||
// ── Local state ───────────────────────────────────────────────────
|
||||
let editIdx = null; // null = adding, number = editing
|
||||
|
||||
// ── Scheduler bridge ──────────────────────────────────────────────
|
||||
// These accessors call into scheduler.js via window.schedulerBridge,
|
||||
// which is set up by scheduler.js after it initializes.
|
||||
function getBridge() {
|
||||
return window.schedulerBridge || {};
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const b = getBridge();
|
||||
return typeof b.getConfig === "function" ? b.getConfig() : null;
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
const b = getBridge();
|
||||
return typeof b.getStatus === "function" ? b.getStatus() : null;
|
||||
}
|
||||
|
||||
function getBookmarks() {
|
||||
const b = getBridge();
|
||||
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
var b = getBridge();
|
||||
if (typeof b.markDirty === "function") b.markDirty();
|
||||
}
|
||||
|
||||
function bmName(id) {
|
||||
const bm = getBookmarks().find(function (b) { return b.id === id; });
|
||||
return bm ? bm.name : String(id || "");
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatFreq(hz) {
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||
return hz + " Hz";
|
||||
}
|
||||
|
||||
// ── Satellite config helpers ──────────────────────────────────────
|
||||
function getSatelliteEntries() {
|
||||
var config = getConfig();
|
||||
return (config && config.satellites && Array.isArray(config.satellites.entries))
|
||||
? config.satellites.entries
|
||||
: [];
|
||||
}
|
||||
|
||||
function ensureSatelliteConfig() {
|
||||
var config = getConfig();
|
||||
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||
if (!config.satellites.entries) config.satellites.entries = [];
|
||||
return config.satellites;
|
||||
}
|
||||
|
||||
function collectSatelliteConfig() {
|
||||
var enabled = dom.enabled ? dom.enabled.checked : false;
|
||||
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||
return {
|
||||
enabled: enabled,
|
||||
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||
entries: getSatelliteEntries(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Render: section ───────────────────────────────────────────────
|
||||
function renderSection() {
|
||||
var config = getConfig();
|
||||
var satCfg = (config && config.satellites) || {};
|
||||
var enabled = !!satCfg.enabled;
|
||||
|
||||
if (dom.enabled) dom.enabled.checked = enabled;
|
||||
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
|
||||
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||
|
||||
renderEntries();
|
||||
renderPassStatus();
|
||||
}
|
||||
|
||||
// ── Render: entries table ─────────────────────────────────────────
|
||||
function renderEntries() {
|
||||
if (!dom.tbody) return;
|
||||
var entries = getSatelliteEntries();
|
||||
var frag = document.createDocumentFragment();
|
||||
|
||||
entries.forEach(function (entry, idx) {
|
||||
var tr = document.createElement("tr");
|
||||
|
||||
var tdSat = document.createElement("td");
|
||||
tdSat.textContent = entry.satellite || "";
|
||||
tr.appendChild(tdSat);
|
||||
|
||||
var tdNorad = document.createElement("td");
|
||||
tdNorad.textContent = entry.norad_id || "";
|
||||
tr.appendChild(tdNorad);
|
||||
|
||||
var tdBm = document.createElement("td");
|
||||
tdBm.textContent = bmName(entry.bookmark_id);
|
||||
tr.appendChild(tdBm);
|
||||
|
||||
var tdEl = document.createElement("td");
|
||||
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
|
||||
tr.appendChild(tdEl);
|
||||
|
||||
var tdPrio = document.createElement("td");
|
||||
tdPrio.textContent = entry.priority || 0;
|
||||
tr.appendChild(tdPrio);
|
||||
|
||||
var tdActions = document.createElement("td");
|
||||
|
||||
var editBtn = document.createElement("button");
|
||||
editBtn.className = "sch-write";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", function () {
|
||||
openForm(entry, idx);
|
||||
});
|
||||
tdActions.appendChild(editBtn);
|
||||
|
||||
var removeBtn = document.createElement("button");
|
||||
removeBtn.className = "sch-write";
|
||||
removeBtn.type = "button";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", function () {
|
||||
removeEntry(idx);
|
||||
});
|
||||
tdActions.appendChild(removeBtn);
|
||||
|
||||
tr.appendChild(tdActions);
|
||||
frag.appendChild(tr);
|
||||
});
|
||||
|
||||
dom.tbody.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ── Render: pass status ───────────────────────────────────────────
|
||||
function renderPassStatus() {
|
||||
if (!dom.passStatus) return;
|
||||
var entries = getSatelliteEntries();
|
||||
if (entries.length === 0) {
|
||||
dom.passStatus.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
var status = getStatus();
|
||||
if (status && status.active_satellite) {
|
||||
dom.passStatus.innerHTML =
|
||||
'<span class="sch-sat-active-badge">PASS ACTIVE: ' +
|
||||
escHtml(status.active_satellite) +
|
||||
'</span>';
|
||||
} else {
|
||||
dom.passStatus.innerHTML =
|
||||
'<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render: bookmark dropdown ─────────────────────────────────────
|
||||
function renderBookmarkSelect(selectedId) {
|
||||
if (!dom.bookmark) return;
|
||||
dom.bookmark.innerHTML = '<option value="">— none —</option>';
|
||||
getBookmarks().forEach(function (bm) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = bm.id;
|
||||
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||
if (bm.id === selectedId) opt.selected = true;
|
||||
dom.bookmark.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry management ──────────────────────────────────────────────
|
||||
function removeEntry(idx) {
|
||||
var sat = ensureSatelliteConfig();
|
||||
sat.entries.splice(idx, 1);
|
||||
renderEntries();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ── Form: open ────────────────────────────────────────────────────
|
||||
function openForm(entry, idx) {
|
||||
editIdx = (idx != null) ? idx : null;
|
||||
|
||||
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||
if (dom.preset) dom.preset.value = "";
|
||||
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
|
||||
if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : "";
|
||||
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
|
||||
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
|
||||
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
|
||||
|
||||
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||
|
||||
if (dom.formWrap) {
|
||||
dom.formWrap.style.display = "flex";
|
||||
if (dom.name) dom.name.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form: close ───────────────────────────────────────────────────
|
||||
function closeForm() {
|
||||
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||
editIdx = null;
|
||||
}
|
||||
|
||||
// ── Form: submit ──────────────────────────────────────────────────
|
||||
function onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var satellite = dom.name ? dom.name.value.trim() : "";
|
||||
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||
var bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||
|
||||
if (!satellite) { alert("Please enter a satellite name."); return; }
|
||||
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
|
||||
if (!bmId) { alert("Please select a bookmark."); return; }
|
||||
|
||||
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||
|
||||
var sat = ensureSatelliteConfig();
|
||||
|
||||
var entryData = {
|
||||
satellite: satellite,
|
||||
norad_id: noradId,
|
||||
bookmark_id: bmId,
|
||||
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||
priority: isNaN(prio) ? 0 : prio,
|
||||
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||
bookmark_ids: [],
|
||||
};
|
||||
|
||||
if (editIdx !== null) {
|
||||
var existing = sat.entries[editIdx];
|
||||
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
|
||||
sat.entries[editIdx] = entryData;
|
||||
} else {
|
||||
entryData.id = "sat_" + Date.now().toString(36);
|
||||
sat.entries.push(entryData);
|
||||
}
|
||||
|
||||
closeForm();
|
||||
renderEntries();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// ── Preset change handler ─────────────────────────────────────────
|
||||
function onPresetChange() {
|
||||
if (!dom.preset || !dom.preset.value) return;
|
||||
var parts = dom.preset.value.split("|");
|
||||
if (dom.name) dom.name.value = parts[0] || "";
|
||||
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||
}
|
||||
|
||||
// ── Wire all events ───────────────────────────────────────────────
|
||||
function wireEvents() {
|
||||
if (dom.enabled) {
|
||||
dom.enabled.addEventListener("change", function () {
|
||||
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
if (dom.pretune) {
|
||||
dom.pretune.addEventListener("input", function () {
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
if (dom.addBtn) dom.addBtn.addEventListener("click", function () { openForm(null, null); });
|
||||
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
|
||||
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
|
||||
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────
|
||||
window.satScheduler = {
|
||||
wireEvents: wireEvents,
|
||||
renderSection: renderSection,
|
||||
renderPassStatus: renderPassStatus,
|
||||
collectSatelliteConfig: collectSatelliteConfig,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,546 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- SAT Plugin ---
|
||||
// Live view: decoder state, latest image card
|
||||
// History view: filterable table of all decoded images
|
||||
// Predictions view: next 24 h passes for ham satellites
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
const satDom = {
|
||||
status: document.getElementById("sat-status"),
|
||||
liveView: document.getElementById("sat-live-view"),
|
||||
historyView: document.getElementById("sat-history-view"),
|
||||
predictionsView: document.getElementById("sat-predictions-view"),
|
||||
liveLatest: document.getElementById("sat-live-latest"),
|
||||
historyList: document.getElementById("sat-history-list"),
|
||||
historyCount: document.getElementById("sat-history-count"),
|
||||
filterInput: document.getElementById("sat-filter"),
|
||||
sortSelect: document.getElementById("sat-sort"),
|
||||
typeFilter: document.getElementById("sat-type-filter"),
|
||||
lrptState: document.getElementById("sat-lrpt-state"),
|
||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||
predFilter: document.getElementById("sat-pred-filter"),
|
||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||
predCategory: document.getElementById("sat-pred-category"),
|
||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||
predStatus: document.getElementById("sat-pred-status"),
|
||||
};
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────
|
||||
let satImageHistory = [];
|
||||
const SAT_MAX_IMAGES = 100;
|
||||
const SAT_PRED_PAGE_SIZE = 50;
|
||||
let satPredShowAll = false;
|
||||
let satFilterText = "";
|
||||
let satActiveView = "live"; // "live" | "history" | "predictions"
|
||||
let satPredData = [];
|
||||
let satPredFilterText = "";
|
||||
let satPredMinEl = 0;
|
||||
let satPredCategory = "all";
|
||||
let satPredSatCount = 0;
|
||||
let satPredCountdownTimer = null;
|
||||
|
||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||
function scheduleSatUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
function switchSatView(view) {
|
||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||
satActiveView = view;
|
||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||
if (leavingPredictions) clearPredictionDom();
|
||||
if (view === "history") {
|
||||
renderSatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
satPredShowAll = false;
|
||||
loadSatPredictions();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPredictionDom() {
|
||||
stopCountdownTimer();
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
}
|
||||
window.clearSatPredictionDom = clearPredictionDom;
|
||||
|
||||
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
|
||||
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
|
||||
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
||||
|
||||
// ── Live view: decoder state ────────────────────────────────────────
|
||||
let _lastSatLrptOn = null;
|
||||
window.updateSatLiveState = function (update) {
|
||||
if (!satDom.lrptState) return;
|
||||
const lrptOn = !!update.lrpt_decode_enabled;
|
||||
if (lrptOn !== _lastSatLrptOn) {
|
||||
_lastSatLrptOn = lrptOn;
|
||||
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||
if (satDom.status) {
|
||||
if (lrptOn) {
|
||||
satDom.status.textContent = "Decoder active \u2014 waiting for signal";
|
||||
} else {
|
||||
satDom.status.textContent = "Decoder idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function renderSatLatestCard() {
|
||||
if (!satDom.liveLatest) return;
|
||||
if (satImageHistory.length === 0) {
|
||||
satDom.liveLatest.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const img = satImageHistory[0];
|
||||
const decoder = img._decoder || "unknown";
|
||||
const typeName = "Meteor LRPT";
|
||||
const satellite = img.satellite || "";
|
||||
const channels = img.channels || img.channel_a || "";
|
||||
const lines = img.mcu_count || img.line_count || 0;
|
||||
const unit = "MCU rows";
|
||||
const ts = img._ts || "--";
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||
|
||||
let meta = [typeName];
|
||||
if (satellite) meta.push(satellite);
|
||||
if (channels) meta.push(channels);
|
||||
meta.push(`${lines} ${unit}`);
|
||||
meta.push(`${date} ${ts}`);
|
||||
|
||||
let html = `<div class="sat-latest-card">`;
|
||||
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
||||
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
||||
if (img.path) {
|
||||
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||
}
|
||||
if (img.geo_bounds) {
|
||||
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
satDom.liveLatest.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── History view: table ─────────────────────────────────────────────
|
||||
function getSatFilteredHistory() {
|
||||
let items = satImageHistory;
|
||||
|
||||
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||
|
||||
if (satFilterText) {
|
||||
items = items.filter((i) => {
|
||||
const haystack = [
|
||||
"meteor lrpt",
|
||||
i.satellite || "",
|
||||
i.channels || "",
|
||||
i.channel_a || "",
|
||||
i.channel_b || "",
|
||||
].join(" ").toUpperCase();
|
||||
return haystack.includes(satFilterText);
|
||||
});
|
||||
}
|
||||
|
||||
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
||||
if (sortVal === "oldest") items = items.slice().reverse();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderSatHistoryRow(img) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-history-row";
|
||||
|
||||
const decoder = img._decoder || "unknown";
|
||||
const typeName = "Meteor LRPT";
|
||||
const typeClass = "sat-type-lrpt";
|
||||
const ts = img._ts || "--";
|
||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||
const satellite = img.satellite || "--";
|
||||
const channels = img.channels || "--";
|
||||
const lines = img.mcu_count || img.line_count || 0;
|
||||
const unit = "MCU";
|
||||
let link = img.path
|
||||
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
||||
: "--";
|
||||
if (img.geo_bounds) {
|
||||
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||
}
|
||||
|
||||
row.innerHTML = [
|
||||
`<span>${date} ${ts}</span>`,
|
||||
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
||||
`<span>${satellite}</span>`,
|
||||
`<span>${channels}</span>`,
|
||||
`<span>${lines} ${unit}</span>`,
|
||||
`<span>${link}</span>`,
|
||||
].join("");
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSatHistoryTable() {
|
||||
if (!satDom.historyList) return;
|
||||
const items = getSatFilteredHistory();
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
fragment.appendChild(renderSatHistoryRow(items[i]));
|
||||
}
|
||||
satDom.historyList.replaceChildren(fragment);
|
||||
|
||||
if (satDom.historyCount) {
|
||||
const total = satImageHistory.length;
|
||||
const shown = items.length;
|
||||
satDom.historyCount.textContent =
|
||||
total === 0
|
||||
? "No images yet"
|
||||
: shown === total
|
||||
? `${total} image${total === 1 ? "" : "s"}`
|
||||
: `${shown} of ${total} images`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add image to history ────────────────────────────────────────────
|
||||
function addSatImage(img, decoder) {
|
||||
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||
img._tsMs = tsMs;
|
||||
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
img._decoder = decoder;
|
||||
|
||||
satImageHistory.unshift(img);
|
||||
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
||||
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||
}
|
||||
|
||||
scheduleSatUi("sat-latest", () => renderSatLatestCard());
|
||||
if (satActiveView === "history") {
|
||||
scheduleSatUi("sat-history", () => renderSatHistoryTable());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server callbacks ────────────────────────────────────────────────
|
||||
window.onServerLrptProgress = function (msg) {
|
||||
if (satDom.status && msg.mcu_count > 0) {
|
||||
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
|
||||
}
|
||||
};
|
||||
|
||||
window.onServerLrptImage = function (msg) {
|
||||
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||
addSatImage(msg, "lrpt");
|
||||
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
||||
window.addSatMapOverlay(msg);
|
||||
}
|
||||
};
|
||||
|
||||
window.resetSatHistoryView = function () {
|
||||
satImageHistory = [];
|
||||
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||
renderSatLatestCard();
|
||||
renderSatHistoryTable();
|
||||
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
|
||||
};
|
||||
|
||||
window.pruneSatHistoryView = function () {
|
||||
renderSatHistoryTable();
|
||||
renderSatLatestCard();
|
||||
};
|
||||
|
||||
// ── Toggle buttons ──────────────────────────────────────────────────
|
||||
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||
await postPath("/toggle_lrpt_decode");
|
||||
} catch (e) {
|
||||
console.error("LRPT toggle failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Filter / sort event listeners ───────────────────────────────────
|
||||
satDom.filterInput?.addEventListener("input", () => {
|
||||
satFilterText = satDom.filterInput.value.trim().toUpperCase();
|
||||
renderSatHistoryTable();
|
||||
});
|
||||
|
||||
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
|
||||
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
||||
|
||||
// ── Settings: clear history ─────────────────────────────────────────
|
||||
document
|
||||
.getElementById("settings-clear-sat-history")
|
||||
?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_lrpt_decode");
|
||||
window.resetSatHistoryView();
|
||||
} catch (e) {
|
||||
console.error("Weather satellite history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Predictions: helpers ────────────────────────────────────────────
|
||||
function azToCardinal(deg) {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8];
|
||||
}
|
||||
|
||||
function formatPredTime(ms) {
|
||||
const d = new Date(ms);
|
||||
const now = new Date();
|
||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : "";
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${day}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function formatPredDuration(s) {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function formatCountdown(ms) {
|
||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function elevationClass(deg) {
|
||||
if (deg >= 45) return "sat-pred-el-high";
|
||||
if (deg >= 10) return "sat-pred-el-mid";
|
||||
return "sat-pred-el-low";
|
||||
}
|
||||
|
||||
// ── Predictions: countdown timer management ─────────────────────────
|
||||
function stopCountdownTimer() {
|
||||
if (satPredCountdownTimer) {
|
||||
clearInterval(satPredCountdownTimer);
|
||||
satPredCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdownTimer(container) {
|
||||
const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : [];
|
||||
if (countdownEls.length === 0) return;
|
||||
|
||||
satPredCountdownTimer = setInterval(() => {
|
||||
if (satActiveView !== "predictions") {
|
||||
stopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
const n = Date.now();
|
||||
let anyActive = false;
|
||||
for (const el of countdownEls) {
|
||||
const los = parseInt(el.dataset.los, 10);
|
||||
const rem = los - n;
|
||||
if (rem > 0) {
|
||||
el.textContent = formatCountdown(rem);
|
||||
anyActive = true;
|
||||
} else {
|
||||
el.textContent = "0:00";
|
||||
}
|
||||
}
|
||||
if (!anyActive) {
|
||||
stopCountdownTimer();
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Predictions: row builders ───────────────────────────────────────
|
||||
function buildCurrentPassRow(pass, now) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row-current";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
const remaining = Math.max(0, pass.los_ms - now);
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildUpcomingPassRow(pass) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "sat-pred-row";
|
||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||
row.innerHTML = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Predictions: filter state ───────────────────────────────────────
|
||||
function getFilteredPredictions() {
|
||||
let items = satPredData;
|
||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||
return items;
|
||||
}
|
||||
|
||||
function applyPredFilters() {
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
|
||||
satDom.predFilter?.addEventListener("input", () => {
|
||||
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
satDom.predMinEl?.addEventListener("change", () => {
|
||||
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
satDom.predCategory?.addEventListener("change", () => {
|
||||
satPredCategory = satDom.predCategory.value;
|
||||
applyPredFilters();
|
||||
});
|
||||
|
||||
// ── Predictions: main render ────────────────────────────────────────
|
||||
function renderSatPredictions(passes, error) {
|
||||
stopCountdownTimer();
|
||||
|
||||
if (error) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||
|
||||
// ── Current passes ──
|
||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||
if (satDom.predCurrentList) {
|
||||
if (current.length === 0) {
|
||||
satDom.predCurrentList.innerHTML = "";
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||
satDom.predCurrentList.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upcoming passes ──
|
||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||
if (satDom.predUpcomingList) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||
if (hiddenCount > 0) {
|
||||
const moreRow = document.createElement("div");
|
||||
moreRow.className = "sat-pred-row";
|
||||
moreRow.style.cursor = "pointer";
|
||||
moreRow.style.textAlign = "center";
|
||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||
moreRow.addEventListener("click", () => {
|
||||
satPredShowAll = true;
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
});
|
||||
frag.appendChild(moreRow);
|
||||
}
|
||||
satDom.predUpcomingList.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
if (satDom.predStatus) {
|
||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
||||
satDom.predStatus.textContent = text;
|
||||
}
|
||||
|
||||
// ── Countdown timer ──
|
||||
if (current.length > 0 && satActiveView === "predictions") {
|
||||
startCountdownTimer(satDom.predCurrentList);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Predictions: data loading ───────────────────────────────────────
|
||||
async function loadSatPredictions() {
|
||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
satPredSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
satPredData = [];
|
||||
renderSatPredictions([], data.error);
|
||||
} else {
|
||||
satPredData = data.passes || [];
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
} catch (e) {
|
||||
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||
window.satShowOnMap = function (south, west, north, east) {
|
||||
if (typeof window.enableMapSourceFilter === "function") {
|
||||
window.enableMapSourceFilter("sat");
|
||||
}
|
||||
const lat = (south + north) / 2;
|
||||
const lon = (west + east) / 2;
|
||||
if (window.navigateToAprsMap) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Initial render ──────────────────────────────────────────────────
|
||||
renderSatLatestCard();
|
||||
renderSatHistoryTable();
|
||||
@@ -0,0 +1,565 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- Virtual Channels Plugin ---
|
||||
//
|
||||
// Handles the `session` and `channels` SSE events emitted by /events and
|
||||
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
||||
|
||||
let vchanSessionId = null;
|
||||
let vchanRigId = null;
|
||||
let vchanChannels = [];
|
||||
let vchanActiveId = null;
|
||||
let schedulerReleaseState = null;
|
||||
let schedulerReleasePollTimer = null;
|
||||
|
||||
function vchanFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
function schedulerReleaseSummaryText(state) {
|
||||
if (!state) return "Scheduler is controlling the rig.";
|
||||
const connected = Number(state.connected_sessions) || 0;
|
||||
const released = Number(state.released_sessions) || 0;
|
||||
if (connected === 0) return "Scheduler can control the rig.";
|
||||
if (state.all_released) {
|
||||
return connected === 1
|
||||
? "Scheduler is controlling the rig."
|
||||
: `Scheduler is controlling the rig for all ${connected} users.`;
|
||||
}
|
||||
if (!state.current_session_released) {
|
||||
const othersReleased = Math.max(released, 0);
|
||||
return othersReleased > 0
|
||||
? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.`
|
||||
: "You are holding control. Release it to return control to the scheduler.";
|
||||
}
|
||||
const blocking = Math.max(connected - released, 0);
|
||||
return blocking > 0
|
||||
? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.`
|
||||
: "Scheduler can control the rig.";
|
||||
}
|
||||
|
||||
function vchanRenderSchedulerRelease() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
const status = document.getElementById("scheduler-release-status");
|
||||
if (!btn || !status) return;
|
||||
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||
btn.disabled = !vchanSessionId || currentReleased;
|
||||
btn.classList.toggle("active", !currentReleased);
|
||||
btn.textContent = "Release to Scheduler";
|
||||
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||
}
|
||||
|
||||
async function vchanPollSchedulerRelease() {
|
||||
if (!vchanSessionId) {
|
||||
schedulerReleaseState = null;
|
||||
vchanRenderSchedulerRelease();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler release status failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function vchanStartSchedulerReleasePolling() {
|
||||
if (schedulerReleasePollTimer) {
|
||||
clearInterval(schedulerReleasePollTimer);
|
||||
}
|
||||
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
|
||||
}
|
||||
|
||||
async function vchanToggleSchedulerRelease() {
|
||||
if (!vchanSessionId) return;
|
||||
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler release toggle failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanTakeSchedulerControl() {
|
||||
if (!vchanSessionId) return;
|
||||
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||
try {
|
||||
const resp = await fetch("/scheduler-control", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
schedulerReleaseState = await resp.json();
|
||||
vchanRenderSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.error("scheduler control takeover failed", e);
|
||||
}
|
||||
}
|
||||
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||
|
||||
// Called by app.js when the SSE `session` event arrives.
|
||||
function vchanHandleSession(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanSessionId = d.session_id || null;
|
||||
vchanPollSchedulerRelease();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad session event", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js when the SSE `channels` event arrives.
|
||||
function vchanHandleChannels(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanRigId = d.remote || null;
|
||||
vchanChannels = d.channels || [];
|
||||
const ids = new Set(vchanChannels.map(c => c.id));
|
||||
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
|
||||
// First channels event for this session — auto-subscribe to channel 0
|
||||
// so we join the same tuned channel as other users on this rig.
|
||||
// Use a direct subscribe (no scheduler control takeover) to avoid
|
||||
// side-effects on initial connect.
|
||||
vchanAutoJoinPrimary(vchanChannels[0].id);
|
||||
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||
// Active channel was evicted — fall back to channel 0 and reconnect audio.
|
||||
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
||||
vchanReconnectAudio();
|
||||
}
|
||||
vchanRender();
|
||||
vchanRenderSchedulerRelease();
|
||||
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad channels event", e);
|
||||
}
|
||||
}
|
||||
|
||||
function vchanRender() {
|
||||
const picker = document.getElementById("vchan-picker");
|
||||
if (!picker) return;
|
||||
picker.innerHTML = "";
|
||||
|
||||
vchanChannels.forEach(ch => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "vchan-label";
|
||||
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||
btn.appendChild(label);
|
||||
|
||||
if (!ch.permanent) {
|
||||
const del = document.createElement("span");
|
||||
del.className = "vchan-del";
|
||||
del.textContent = "\u00d7";
|
||||
del.title = "Delete channel";
|
||||
del.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
vchanDelete(ch.id);
|
||||
});
|
||||
btn.appendChild(del);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
||||
});
|
||||
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
// "+" button — allocate a new channel at the current VFO frequency.
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "vchan-add";
|
||||
addBtn.textContent = "+";
|
||||
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||
addBtn.addEventListener("click", vchanAllocate);
|
||||
picker.appendChild(addBtn);
|
||||
|
||||
vchanSyncAccentUI();
|
||||
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||
updateDocumentTitle(activeChannelRds());
|
||||
}
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
async function vchanAllocate() {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
|
||||
// Use the last known rig frequency and mode as the starting point.
|
||||
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
|
||||
? lastFreqHz
|
||||
: 0;
|
||||
const modeEl = document.getElementById("mode");
|
||||
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await resp.text().catch(() => String(resp.status));
|
||||
console.warn("vchan: allocate failed —", msg);
|
||||
return;
|
||||
}
|
||||
const ch = await resp.json();
|
||||
vchanActiveId = ch.id;
|
||||
// The SSE `channels` event will trigger vchanRender(); optimistically
|
||||
// mark active so the picker feels responsive even before the event arrives.
|
||||
vchanRender();
|
||||
vchanReconnectAudio();
|
||||
} catch (e) {
|
||||
console.error("vchan: allocate error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanDelete(channelId) {
|
||||
if (!vchanRigId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: delete failed", resp.status);
|
||||
}
|
||||
// Channel list updates via SSE `channels` event.
|
||||
} catch (e) {
|
||||
console.error("vchan: delete error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight auto-join for initial connect: registers the session on
|
||||
// channel 0 without taking scheduler control or reconnecting audio
|
||||
// (audio isn't started yet at this point).
|
||||
async function vchanAutoJoinPrimary(channelId) {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: auto-join primary failed", resp.status);
|
||||
return;
|
||||
}
|
||||
vchanActiveId = channelId;
|
||||
vchanRender();
|
||||
} catch (e) {
|
||||
console.error("vchan: auto-join error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanSubscribe(channelId) {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
try {
|
||||
await vchanTakeSchedulerControl();
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: subscribe failed", resp.status);
|
||||
return;
|
||||
}
|
||||
vchanActiveId = channelId;
|
||||
vchanRender();
|
||||
vchanSyncModeDisplay();
|
||||
vchanReconnectAudio();
|
||||
} catch (e) {
|
||||
console.error("vchan: subscribe error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect the audio WebSocket to the appropriate endpoint:
|
||||
// - virtual channel: /audio?channel_id=<uuid>
|
||||
// - primary channel: /audio (no param)
|
||||
// Always updates _audioChannelOverride so that starting audio later
|
||||
// connects to the correct channel. Only reconnects if RX audio is active.
|
||||
function vchanReconnectAudio() {
|
||||
// Always update the override so startRxAudio picks up the right URL,
|
||||
// even when audio isn't currently running.
|
||||
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||
if (typeof _audioChannelOverride !== "undefined") {
|
||||
_audioChannelOverride = ch ? ch.id : null;
|
||||
}
|
||||
if (typeof rxActive === "undefined" || !rxActive) return;
|
||||
if (typeof stopRxAudio === "function") stopRxAudio();
|
||||
// Delay so the server has time to set up the per-channel encoder.
|
||||
// The server-side audio_ws handler also polls for up to 2 s, so this
|
||||
// just needs to be long enough for the WS upgrade to reach the server.
|
||||
setTimeout(() => {
|
||||
if (typeof startRxAudio === "function") startRxAudio();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Called by app.js from applyCapabilities().
|
||||
// Shows the channel picker only for SDR rigs.
|
||||
function vchanApplyCapabilities(caps) {
|
||||
const picker = document.getElementById("vchan-picker");
|
||||
if (!picker) return;
|
||||
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
|
||||
vchanRenderSchedulerRelease();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freq / mode interception + UI accent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Returns true when the active channel is a non-primary (virtual) channel.
|
||||
function vchanIsOnVirtual() {
|
||||
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||
return vchanActiveId !== vchanChannels[0].id;
|
||||
}
|
||||
|
||||
function vchanActiveChannel() {
|
||||
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
||||
}
|
||||
|
||||
// Update the main freq input to show the virtual channel's frequency.
|
||||
function vchanUpdateFreqDisplay() {
|
||||
const ch = vchanActiveChannel();
|
||||
if (!ch) return;
|
||||
const el = document.getElementById("freq");
|
||||
if (!el) return;
|
||||
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
|
||||
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
|
||||
} else {
|
||||
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the mode picker to the active virtual channel's mode.
|
||||
// Called whenever the active channel changes or the channel list is refreshed.
|
||||
function vchanSyncModeDisplay() {
|
||||
const modeEl = document.getElementById("mode");
|
||||
if (!modeEl) return;
|
||||
if (vchanIsOnVirtual()) {
|
||||
const ch = vchanActiveChannel();
|
||||
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||
}
|
||||
// When on primary channel, app.js rig-state updates handle the picker.
|
||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||
if (typeof lastModeName !== "undefined") {
|
||||
if (modeUpper === "WFM" && lastModeName !== "WFM") {
|
||||
if (typeof setJogDivisor === "function") setJogDivisor(10);
|
||||
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
|
||||
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||
}
|
||||
lastModeName = modeUpper;
|
||||
}
|
||||
if (typeof updateWfmControls === "function") updateWfmControls();
|
||||
if (typeof updateSdrSquelchControlVisibility === "function") {
|
||||
updateSdrSquelchControlVisibility();
|
||||
}
|
||||
if (typeof refreshRdsUi === "function") {
|
||||
refreshRdsUi();
|
||||
} else if (typeof positionRdsPsOverlay === "function") {
|
||||
positionRdsPsOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the BW input to the active virtual channel's bandwidth.
|
||||
function vchanSyncBwDisplay() {
|
||||
if (!vchanIsOnVirtual()) return;
|
||||
const ch = vchanActiveChannel();
|
||||
if (!ch) return;
|
||||
const bwEl = document.getElementById("spectrum-bw-input");
|
||||
if (!bwEl) return;
|
||||
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
||||
let bwHz = ch.bandwidth_hz || 0;
|
||||
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
|
||||
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
|
||||
}
|
||||
if (bwHz > 0) {
|
||||
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||
if (typeof currentBandwidthHz !== "undefined") {
|
||||
currentBandwidthHz = bwHz;
|
||||
window.currentBandwidthHz = bwHz;
|
||||
} else {
|
||||
window.currentBandwidthHz = bwHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add / remove the vchan accent class from the freq and BW inputs.
|
||||
function vchanSyncAccentUI() {
|
||||
const onVirtual = vchanIsOnVirtual();
|
||||
const freqEl = document.getElementById("freq");
|
||||
const bwEl = document.getElementById("spectrum-bw-input");
|
||||
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||
if (onVirtual) {
|
||||
vchanUpdateFreqDisplay();
|
||||
vchanSyncModeDisplay();
|
||||
vchanSyncBwDisplay();
|
||||
} else if (typeof _origRefreshFreqDisplay === "function") {
|
||||
_origRefreshFreqDisplay();
|
||||
}
|
||||
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||
updateDocumentTitle(activeChannelRds());
|
||||
}
|
||||
}
|
||||
|
||||
// Saved reference to the original refreshFreqDisplay from app.js.
|
||||
let _origRefreshFreqDisplay = null;
|
||||
|
||||
function vchanSetChannelFreq(freqHz) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
// Validate against current SDR capture window.
|
||||
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
|
||||
lastSpectrumData.sample_rate > 0) {
|
||||
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
|
||||
const center = Number(lastSpectrumData.center_hz);
|
||||
if (Math.abs(freqHz - center) > halfSpan) {
|
||||
if (typeof showHint === "function") {
|
||||
showHint(
|
||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||
3000
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fire-and-forget: scheduler control + channel freq PUT run in background.
|
||||
vchanTakeSchedulerControl();
|
||||
fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
|
||||
}
|
||||
).catch(e => console.error("vchan: set freq error", e));
|
||||
}
|
||||
|
||||
async function vchanSetChannelBandwidth(bwHz) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
try {
|
||||
await vchanTakeSchedulerControl();
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
||||
} catch (e) {
|
||||
console.error("vchan: set bw error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanSetChannelMode(mode) {
|
||||
if (!vchanRigId || !vchanActiveId) return;
|
||||
try {
|
||||
await vchanTakeSchedulerControl();
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
||||
} catch (e) {
|
||||
console.error("vchan: set mode error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
||||
// sending /set_mode to the server. Returns true if the change was handled
|
||||
// by the virtual channel (caller should skip the server request).
|
||||
window.vchanInterceptMode = async function(mode) {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
await vchanSetChannelMode(mode);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Called by app.js bandwidth setters before sending /set_bandwidth to the
|
||||
// server. Returns true if the change was handled by the virtual channel.
|
||||
window.vchanInterceptBandwidth = async function(bwHz) {
|
||||
if (!vchanIsOnVirtual()) return false;
|
||||
await vchanSetChannelBandwidth(bwHz);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
||||
// frequency changes are redirected to the active virtual channel instead of
|
||||
// the server when on a non-primary channel.
|
||||
(function() {
|
||||
const _orig = window.setRigFrequency;
|
||||
window.setRigFrequency = function(freqHz) {
|
||||
if (vchanIsOnVirtual()) {
|
||||
// Optimistic local update first, then fire-and-forget channel API.
|
||||
if (typeof applyLocalTunedFrequency === "function") {
|
||||
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||
++_freqOptimisticSeq;
|
||||
_freqOptimisticHz = Math.round(freqHz);
|
||||
}
|
||||
applyLocalTunedFrequency(Math.round(freqHz));
|
||||
}
|
||||
vchanSetChannelFreq(freqHz);
|
||||
return;
|
||||
}
|
||||
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||
vchanTakeSchedulerControl();
|
||||
if (typeof _orig === "function") _orig(freqHz);
|
||||
};
|
||||
})();
|
||||
|
||||
(function initSchedulerReleaseControl() {
|
||||
const btn = document.getElementById("scheduler-release-btn");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", () => {
|
||||
vchanToggleSchedulerRelease();
|
||||
});
|
||||
}
|
||||
vchanStartSchedulerReleasePolling();
|
||||
vchanRenderSchedulerRelease();
|
||||
})();
|
||||
|
||||
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
|
||||
// active virtual channel's frequency (SSE rig-state updates would otherwise
|
||||
// constantly overwrite it with channel 0's freq).
|
||||
(function() {
|
||||
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
||||
window.refreshFreqDisplay = function() {
|
||||
if (vchanIsOnVirtual()) {
|
||||
vchanUpdateFreqDisplay();
|
||||
return;
|
||||
}
|
||||
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,352 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- VDES Decoder Plugin (server-side decode) ---
|
||||
const vdesStatus = document.getElementById("vdes-status");
|
||||
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
let vdesFilterText = "";
|
||||
let vdesMessageHistory = [];
|
||||
|
||||
function currentVdesHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneVdesMessageHistory() {
|
||||
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleVdesUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
function scheduleVdesHistoryRender() {
|
||||
scheduleVdesUi("vdes-history", () => renderVdesHistory());
|
||||
}
|
||||
|
||||
function scheduleVdesBarUpdate() {
|
||||
scheduleVdesUi("vdes-bar", () => updateVdesBar());
|
||||
}
|
||||
|
||||
function currentVdesCenterText() {
|
||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||
const hz = raw ? Number(raw) : 0;
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
|
||||
}
|
||||
|
||||
function vdesAgeText(tsMs) {
|
||||
if (!Number.isFinite(tsMs)) return "just now";
|
||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||
const seconds = Math.round(deltaMs / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function vdesHexPreview(rawBytes) {
|
||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||
return rawBytes
|
||||
.slice(0, 20)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function updateVdesSummary() {
|
||||
pruneVdesMessageHistory();
|
||||
if (vdesChannelSummaryEl) {
|
||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||
}
|
||||
if (vdesFrameCountEl) {
|
||||
const count = vdesMessageHistory.length;
|
||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (vdesLatestSeenEl) {
|
||||
const latest = vdesMessageHistory[0];
|
||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||
}
|
||||
}
|
||||
|
||||
function applyVdesFilterToRow(row) {
|
||||
if (!vdesFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const text = row.dataset.filterText || "";
|
||||
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyVdesFilterToAll() {
|
||||
if (!vdesMessagesEl) return;
|
||||
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||
}
|
||||
|
||||
function renderVdesRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "vdes-message";
|
||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const title = msg.vessel_name || "VDES Burst";
|
||||
const label = msg.callsign || "VDES";
|
||||
const info = msg.destination || "";
|
||||
const labelText = msg.message_label || "";
|
||||
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||
const fecText = msg.fec_state || "";
|
||||
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
|
||||
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
|
||||
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
|
||||
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
|
||||
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
|
||||
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
|
||||
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
|
||||
const previewText = msg.payload_preview || "";
|
||||
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||
row.dataset.filterText = [
|
||||
title,
|
||||
label,
|
||||
labelText,
|
||||
info,
|
||||
srcText,
|
||||
dstText,
|
||||
sessionText,
|
||||
asmText,
|
||||
countText,
|
||||
ackText,
|
||||
cqiText,
|
||||
previewText,
|
||||
linkText,
|
||||
syncText,
|
||||
phaseText,
|
||||
fecText,
|
||||
rawHex,
|
||||
msg.message_type,
|
||||
msg.bit_len,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
row.innerHTML =
|
||||
`<div class="vdes-row-head">` +
|
||||
`<span class="vdes-time">${ts}</span>` +
|
||||
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
||||
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
||||
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") +
|
||||
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
|
||||
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") +
|
||||
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") +
|
||||
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
|
||||
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
|
||||
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="vdes-row-meta">` +
|
||||
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
||||
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
||||
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") +
|
||||
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") +
|
||||
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") +
|
||||
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") +
|
||||
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") +
|
||||
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
|
||||
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="vdes-row-detail">` +
|
||||
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") +
|
||||
(previewText ? `<span>·</span>` : "") +
|
||||
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` +
|
||||
`</div>`;
|
||||
applyVdesFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateVdesBar() {
|
||||
if (!vdesBarOverlay) return;
|
||||
updateVdesSummary();
|
||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
|
||||
if (!isVdes || messages.length === 0) {
|
||||
vdesBarOverlay.style.display = "none";
|
||||
vdesBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||
for (const msg of messages) {
|
||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||
const label = escapeMapHtml(msg.callsign || "VDES");
|
||||
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||
const detail = [
|
||||
`${msg.bit_len || 0} bits`,
|
||||
msg.message_label ? escapeMapHtml(msg.message_label) : null,
|
||||
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
|
||||
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
|
||||
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||
}
|
||||
vdesBarOverlay.innerHTML = html;
|
||||
vdesBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateVdesBar = updateVdesBar;
|
||||
window.clearVdesBar = function() {
|
||||
window.resetVdesHistoryView();
|
||||
};
|
||||
|
||||
window.resetVdesHistoryView = function() {
|
||||
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||
vdesMessageHistory = [];
|
||||
updateVdesBar();
|
||||
renderVdesHistory();
|
||||
};
|
||||
|
||||
function renderVdesHistory() {
|
||||
pruneVdesMessageHistory();
|
||||
if (!vdesMessagesEl) {
|
||||
updateVdesSummary();
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
|
||||
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
|
||||
}
|
||||
vdesMessagesEl.replaceChildren(fragment);
|
||||
updateVdesSummary();
|
||||
}
|
||||
|
||||
function addVdesMessage(msg) {
|
||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
vdesMessageHistory.unshift(msg);
|
||||
pruneVdesMessageHistory();
|
||||
scheduleVdesBarUpdate();
|
||||
scheduleVdesHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerVdesMessage(msg) {
|
||||
return {
|
||||
rig_id: msg.rig_id || null,
|
||||
message_type: msg.message_type,
|
||||
bit_len: msg.bit_len,
|
||||
raw_bytes: msg.raw_bytes,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
message_label: msg.message_label,
|
||||
session_id: msg.session_id,
|
||||
source_id: msg.source_id,
|
||||
destination_id: msg.destination_id,
|
||||
data_count: msg.data_count,
|
||||
asm_identifier: msg.asm_identifier,
|
||||
ack_nack_mask: msg.ack_nack_mask,
|
||||
channel_quality: msg.channel_quality,
|
||||
payload_preview: msg.payload_preview,
|
||||
link_id: msg.link_id,
|
||||
sync_score: msg.sync_score,
|
||||
sync_errors: msg.sync_errors,
|
||||
phase_rotation: msg.phase_rotation,
|
||||
fec_state: msg.fec_state,
|
||||
ts_ms: msg.ts_ms,
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerVdesBatch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||
next._tsMs = tsMs;
|
||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||
window.vdesMapAddPoint(next);
|
||||
}
|
||||
normalized.push(next);
|
||||
}
|
||||
normalized.reverse();
|
||||
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||
pruneVdesMessageHistory();
|
||||
scheduleVdesBarUpdate();
|
||||
scheduleVdesHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreVdesHistory = function(messages) {
|
||||
window.onServerVdesBatch(messages);
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_vdes_decode");
|
||||
window.resetVdesHistoryView();
|
||||
} catch (e) {
|
||||
console.error("VDES history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (vdesFilterInput) {
|
||||
vdesFilterInput.addEventListener("input", () => {
|
||||
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||
renderVdesHistory();
|
||||
});
|
||||
}
|
||||
|
||||
window.onServerVdes = function(msg) {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
const next = normalizeServerVdesMessage(msg);
|
||||
addVdesMessage(next);
|
||||
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||
window.vdesMapAddPoint(next);
|
||||
}
|
||||
};
|
||||
|
||||
window.pruneVdesHistoryView = function() {
|
||||
pruneVdesMessageHistory();
|
||||
updateVdesBar();
|
||||
renderVdesHistory();
|
||||
};
|
||||
|
||||
updateVdesSummary();
|
||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
|
||||
@@ -0,0 +1,386 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wefax.js — WEFAX decoder plugin for trx-frontend-http
|
||||
// Live view: decoder state, live canvas, latest image card
|
||||
// History view: filterable table of all decoded images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ── DOM references (cached once) ───────────────────────────────────
|
||||
var wefaxDom = {
|
||||
status: document.getElementById('wefax-status'),
|
||||
liveView: document.getElementById('wefax-live-view'),
|
||||
historyView: document.getElementById('wefax-history-view'),
|
||||
liveContainer: document.getElementById('wefax-live-container'),
|
||||
liveInfo: document.getElementById('wefax-live-info'),
|
||||
liveCanvas: document.getElementById('wefax-live-canvas'),
|
||||
liveLatest: document.getElementById('wefax-live-latest'),
|
||||
historyList: document.getElementById('wefax-history-list'),
|
||||
historyCount: document.getElementById('wefax-history-count'),
|
||||
filterInput: document.getElementById('wefax-filter'),
|
||||
sortSelect: document.getElementById('wefax-sort'),
|
||||
toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
|
||||
clearBtn: document.getElementById('wefax-clear-btn'),
|
||||
viewLiveBtn: document.getElementById('wefax-view-live'),
|
||||
viewHistoryBtn: document.getElementById('wefax-view-history'),
|
||||
};
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────
|
||||
var wefaxImageHistory = [];
|
||||
var WEFAX_MAX_IMAGES = 100;
|
||||
var wefaxLiveCtx = null;
|
||||
var wefaxLiveLineCount = 0;
|
||||
var wefaxLivePixelsPerLine = 1809;
|
||||
var wefaxActiveView = 'live';
|
||||
var wefaxFilterText = '';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
function currentWefaxHistoryRetentionMs() {
|
||||
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneWefaxHistory() {
|
||||
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function scheduleWefaxUi(key, job) {
|
||||
if (typeof window.trxScheduleUiFrameJob === 'function') {
|
||||
window.trxScheduleUiFrameJob(key, job);
|
||||
return;
|
||||
}
|
||||
job();
|
||||
}
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
function switchWefaxView(view) {
|
||||
wefaxActiveView = view;
|
||||
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
|
||||
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
|
||||
|
||||
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function (btn) {
|
||||
if (btn) btn.classList.remove('sat-view-active');
|
||||
});
|
||||
if (view === 'live' && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add('sat-view-active');
|
||||
if (view === 'history' && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add('sat-view-active');
|
||||
|
||||
if (view === 'history') renderWefaxHistoryTable();
|
||||
}
|
||||
|
||||
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener('click', function () { switchWefaxView('live'); });
|
||||
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
|
||||
|
||||
// ── Live canvas rendering ───────────────────────────────────────────
|
||||
function resetLiveCanvas(pixelsPerLine) {
|
||||
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||
wefaxLiveLineCount = 0;
|
||||
wefaxDom.liveCanvas.width = pixelsPerLine;
|
||||
wefaxDom.liveCanvas.height = 800;
|
||||
wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
|
||||
wefaxLiveCtx.fillStyle = '#000';
|
||||
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
|
||||
}
|
||||
|
||||
function paintLine(lineBytes) {
|
||||
if (!wefaxLiveCtx) return;
|
||||
var y = wefaxLiveLineCount;
|
||||
|
||||
if (y >= wefaxDom.liveCanvas.height) {
|
||||
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||
wefaxDom.liveCanvas.height *= 2;
|
||||
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||
}
|
||||
|
||||
var w = wefaxLivePixelsPerLine;
|
||||
var imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||
var d = imgData.data;
|
||||
for (var x = 0; x < w; x++) {
|
||||
var v = x < lineBytes.length ? lineBytes[x] : 0;
|
||||
var i = x * 4;
|
||||
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
|
||||
}
|
||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||
wefaxLiveLineCount++;
|
||||
}
|
||||
|
||||
// ── Live view: latest image card ────────────────────────────────────
|
||||
function renderWefaxLatestCard() {
|
||||
if (!wefaxDom.liveLatest) return;
|
||||
if (wefaxImageHistory.length === 0) {
|
||||
wefaxDom.liveLatest.innerHTML =
|
||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var img = wefaxImageHistory[0];
|
||||
var ts = img._ts || '--';
|
||||
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||
var meta = [
|
||||
img.ioc + ' IOC',
|
||||
img.lpm + ' LPM',
|
||||
img.line_count + ' lines',
|
||||
date + ' ' + ts,
|
||||
].join(' \u00b7 ');
|
||||
|
||||
var imgSrc = img._dataUrl
|
||||
? img._dataUrl
|
||||
: img.path
|
||||
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||
: null;
|
||||
|
||||
var html = '<div class="sat-latest-card">';
|
||||
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + '</div>';
|
||||
if (imgSrc) {
|
||||
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
wefaxDom.liveLatest.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── History view: table ─────────────────────────────────────────────
|
||||
function getWefaxFilteredHistory() {
|
||||
var items = wefaxImageHistory;
|
||||
|
||||
if (wefaxFilterText) {
|
||||
items = items.filter(function (i) {
|
||||
var haystack = [
|
||||
String(i.ioc || ''),
|
||||
String(i.lpm || ''),
|
||||
String(i.line_count || ''),
|
||||
].join(' ').toUpperCase();
|
||||
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
|
||||
if (sortVal === 'oldest') items = items.slice().reverse();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderWefaxHistoryRow(img) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'sat-history-row';
|
||||
|
||||
var ts = img._ts || '--';
|
||||
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
||||
var ioc = img.ioc || '--';
|
||||
var lpm = img.lpm || '--';
|
||||
var lines = img.line_count || 0;
|
||||
|
||||
var imgSrc = img._dataUrl
|
||||
? img._dataUrl
|
||||
: img.path
|
||||
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||
: null;
|
||||
var link = imgSrc
|
||||
? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>'
|
||||
: '--';
|
||||
|
||||
row.innerHTML = [
|
||||
'<span>' + escapeHtml(date + ' ' + ts) + '</span>',
|
||||
'<span>' + escapeHtml(String(ioc)) + '</span>',
|
||||
'<span>' + escapeHtml(String(lpm)) + '</span>',
|
||||
'<span>' + lines + '</span>',
|
||||
'<span>' + link + '</span>',
|
||||
].join('');
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderWefaxHistoryTable() {
|
||||
if (!wefaxDom.historyList) return;
|
||||
pruneWefaxHistory();
|
||||
var items = getWefaxFilteredHistory();
|
||||
var fragment = document.createDocumentFragment();
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
fragment.appendChild(renderWefaxHistoryRow(items[i]));
|
||||
}
|
||||
wefaxDom.historyList.replaceChildren(fragment);
|
||||
|
||||
if (wefaxDom.historyCount) {
|
||||
var total = wefaxImageHistory.length;
|
||||
var shown = items.length;
|
||||
wefaxDom.historyCount.textContent =
|
||||
total === 0
|
||||
? 'No images yet'
|
||||
: shown === total
|
||||
? total + ' image' + (total === 1 ? '' : 's')
|
||||
: shown + ' of ' + total + ' images';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add image to history ────────────────────────────────────────────
|
||||
function addWefaxImage(msg) {
|
||||
var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
msg._tsMs = tsMs;
|
||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
// Capture the live canvas as a data URI for thumbnails.
|
||||
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
|
||||
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
|
||||
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
|
||||
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||
try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
|
||||
}
|
||||
|
||||
wefaxImageHistory.unshift(msg);
|
||||
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||
}
|
||||
|
||||
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
|
||||
if (wefaxActiveView === 'history') {
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||
window.onServerWefaxProgress = function (msg) {
|
||||
// State-only update (no image data): show decoder state in status.
|
||||
if (msg.state && !msg.line_data) {
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = msg.state;
|
||||
// Highlight active states, dim idle/scanning.
|
||||
wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.line_count <= 1 || !wefaxLiveCtx) {
|
||||
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||
}
|
||||
|
||||
if (msg.line_data) {
|
||||
var binary = atob(msg.line_data);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
paintLine(bytes);
|
||||
}
|
||||
|
||||
if (wefaxDom.liveInfo) {
|
||||
wefaxDom.liveInfo.textContent =
|
||||
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
|
||||
}
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
|
||||
wefaxDom.status.style.color = 'var(--text-accent)';
|
||||
}
|
||||
};
|
||||
|
||||
window.onServerWefax = function (msg) {
|
||||
addWefaxImage(msg);
|
||||
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
|
||||
wefaxDom.status.style.color = '';
|
||||
}
|
||||
};
|
||||
|
||||
window.restoreWefaxHistory = function (messages) {
|
||||
if (!messages || !messages.length) return;
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
|
||||
messages[i]._tsMs = tsMs;
|
||||
messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||
pruneWefaxHistory();
|
||||
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
|
||||
if (wefaxActiveView === 'history') {
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
}
|
||||
};
|
||||
|
||||
window.pruneWefaxHistoryView = function () {
|
||||
pruneWefaxHistory();
|
||||
renderWefaxHistoryTable();
|
||||
renderWefaxLatestCard();
|
||||
};
|
||||
|
||||
window.resetWefaxHistoryView = function () {
|
||||
wefaxImageHistory = [];
|
||||
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
|
||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||
wefaxLiveCtx = null;
|
||||
wefaxLiveLineCount = 0;
|
||||
renderWefaxLatestCard();
|
||||
renderWefaxHistoryTable();
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = 'Idle';
|
||||
wefaxDom.status.style.color = '';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Filter / sort handlers ──────────────────────────────────────────
|
||||
if (wefaxDom.filterInput) {
|
||||
wefaxDom.filterInput.addEventListener('input', function () {
|
||||
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
});
|
||||
}
|
||||
if (wefaxDom.sortSelect) {
|
||||
wefaxDom.sortSelect.addEventListener('change', function () {
|
||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Toggle button sync ──────────────────────────────────────────────
|
||||
// Sync the Enable/Disable button from the SSE state update. This is
|
||||
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
|
||||
// WEFAX button always reflects the server state.
|
||||
window.syncWefaxToggle = function (enabled) {
|
||||
if (!wefaxDom.toggleBtn) return;
|
||||
wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
|
||||
wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
|
||||
wefaxDom.toggleBtn.style.borderColor = enabled ? '#00d17f' : '';
|
||||
wefaxDom.toggleBtn.style.color = enabled ? '#00d17f' : '';
|
||||
};
|
||||
|
||||
// ── Button handlers ─────────────────────────────────────────────────
|
||||
if (wefaxDom.toggleBtn) {
|
||||
wefaxDom.toggleBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
if (window.takeSchedulerControlForDecoderDisable) {
|
||||
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
|
||||
}
|
||||
await postPath('/toggle_wefax_decode');
|
||||
} catch (e) {
|
||||
console.error('WEFAX toggle failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (wefaxDom.clearBtn) {
|
||||
wefaxDom.clearBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
await postPath('/clear_wefax_decode');
|
||||
window.resetWefaxHistoryView();
|
||||
} catch (e) {
|
||||
console.error('WEFAX clear failed', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial render ──────────────────────────────────────────────────
|
||||
renderWefaxLatestCard();
|
||||
@@ -0,0 +1,292 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- WSPR Decoder Plugin (server-side decode) ---
|
||||
const wsprStatus = document.getElementById("wspr-status");
|
||||
const wsprPeriodEl = document.getElementById("wspr-period");
|
||||
const wsprMessagesEl = document.getElementById("wspr-messages");
|
||||
const wsprFilterInput = document.getElementById("wspr-filter");
|
||||
const WSPR_PERIOD_SECONDS = 120;
|
||||
let wsprFilterText = "";
|
||||
let wsprMessageHistory = [];
|
||||
|
||||
function currentWsprHistoryRetentionMs() {
|
||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||
? window.getDecodeHistoryRetentionMs()
|
||||
: 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function pruneWsprMessageHistory() {
|
||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||
}
|
||||
|
||||
function scheduleWsprHistoryRender() {
|
||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
||||
return;
|
||||
}
|
||||
renderWsprHistory();
|
||||
}
|
||||
|
||||
function fmtWsprTime(tsMs) {
|
||||
if (!tsMs) return "--:--:--";
|
||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function updateWsprPeriodTimer() {
|
||||
if (!wsprPeriodEl) return;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
|
||||
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||
const ss = String(remaining % 60).padStart(2, "0");
|
||||
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||
}
|
||||
|
||||
updateWsprPeriodTimer();
|
||||
setInterval(updateWsprPeriodTimer, 500);
|
||||
|
||||
function renderWsprRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ft8-row";
|
||||
row.dataset.decoder = "wspr";
|
||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
|
||||
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
||||
const message = (msg.message || "").toString();
|
||||
row.dataset.message = message.toUpperCase();
|
||||
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||
applyWsprFilterToRow(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderWsprHistory() {
|
||||
pruneWsprMessageHistory();
|
||||
if (!wsprMessagesEl) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
||||
}
|
||||
wsprMessagesEl.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function addWsprMessage(msg) {
|
||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||
wsprMessageHistory.unshift(msg);
|
||||
pruneWsprMessageHistory();
|
||||
scheduleWsprHistoryRender();
|
||||
}
|
||||
|
||||
function normalizeServerWsprMessage(msg) {
|
||||
const raw = (msg.message || "").toString();
|
||||
const grids = extractAllGrids(raw);
|
||||
const station = extractLikelyCallsign(raw);
|
||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
|
||||
? (baseHz + Number(msg.freq_hz))
|
||||
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
|
||||
return {
|
||||
raw,
|
||||
grids,
|
||||
station,
|
||||
rfHz,
|
||||
history: {
|
||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||
ts_ms: msg.ts_ms,
|
||||
snr_db: msg.snr_db,
|
||||
dt_s: msg.dt_s,
|
||||
freq_hz: msg.freq_hz,
|
||||
message: raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
window.onServerWsprBatch = function(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
wsprStatus.textContent = "Receiving";
|
||||
const normalized = [];
|
||||
for (const msg of messages) {
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
});
|
||||
}
|
||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||
normalized.push(next.history);
|
||||
}
|
||||
normalized.reverse();
|
||||
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||
pruneWsprMessageHistory();
|
||||
scheduleWsprHistoryRender();
|
||||
};
|
||||
|
||||
window.restoreWsprHistory = function(messages) {
|
||||
window.onServerWsprBatch(messages);
|
||||
};
|
||||
|
||||
window.pruneWsprHistoryView = function() {
|
||||
pruneWsprMessageHistory();
|
||||
renderWsprHistory();
|
||||
};
|
||||
|
||||
function escapeWsprHtml(input) {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
function renderWsprMessage(message) {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < message.length) {
|
||||
const ch = message[i];
|
||||
if (isAlphaNum(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||
const token = message.slice(i, j);
|
||||
const grid = token.toUpperCase();
|
||||
if (isMaidenheadGridToken(grid)) {
|
||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||
} else {
|
||||
out += escapeWsprHtml(token);
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
out += escapeWsprHtml(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractAllGrids(message) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||
for (const token of parts) {
|
||||
if (!token) continue;
|
||||
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
||||
seen.add(token);
|
||||
out.push(token);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractLikelyCallsign(message) {
|
||||
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
||||
for (const token of parts) {
|
||||
if (!token) continue;
|
||||
if (token.length < 3 || token.length > 12) continue;
|
||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
||||
if (isMaidenheadGridToken(token)) continue;
|
||||
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFtxFarewellToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||
}
|
||||
|
||||
function isMaidenheadGridToken(token) {
|
||||
const normalized = String(token || "").trim().toUpperCase();
|
||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||
}
|
||||
|
||||
function isAlphaNum(ch) {
|
||||
return /[A-Za-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
function activateWsprHistoryLocator(targetEl) {
|
||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||
if (!locatorEl) return false;
|
||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||
if (!grid) return false;
|
||||
if (typeof window.navigateToMapLocator === "function") {
|
||||
window.navigateToMapLocator(grid, "wspr");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyWsprFilterToRow(row) {
|
||||
if (!wsprFilterText) {
|
||||
row.style.display = "";
|
||||
return;
|
||||
}
|
||||
const message = row.dataset.message || "";
|
||||
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||
}
|
||||
|
||||
function applyWsprFilterToAll() {
|
||||
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
||||
rows.forEach((row) => applyWsprFilterToRow(row));
|
||||
}
|
||||
|
||||
window.resetWsprHistoryView = function() {
|
||||
wsprMessagesEl.innerHTML = "";
|
||||
wsprMessageHistory = [];
|
||||
renderWsprHistory();
|
||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
||||
};
|
||||
|
||||
if (wsprFilterInput) {
|
||||
wsprFilterInput.addEventListener("input", () => {
|
||||
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||
renderWsprHistory();
|
||||
});
|
||||
}
|
||||
|
||||
if (wsprMessagesEl) {
|
||||
wsprMessagesEl.addEventListener("click", (event) => {
|
||||
if (!activateWsprHistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
wsprMessagesEl.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (!activateWsprHistoryLocator(event.target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_wspr_decode");
|
||||
window.resetWsprHistoryView();
|
||||
} catch (e) {
|
||||
console.error("WSPR history clear failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
window.onServerWspr = function(msg) {
|
||||
wsprStatus.textContent = "Receiving";
|
||||
const next = normalizeServerWsprMessage(msg);
|
||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||
...msg,
|
||||
freq_hz: next.rfHz,
|
||||
});
|
||||
}
|
||||
addWsprMessage(next.history);
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
|
||||
// Communicates with app.js core via window.trx namespace.
|
||||
(function () {
|
||||
"use strict";
|
||||
const T = window.trx;
|
||||
|
||||
function isVisibleForSnapshot(el) {
|
||||
if (!el) return false;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
const opacity = Number(style.opacity);
|
||||
if (Number.isFinite(opacity) && opacity <= 0) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + w - radius, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
||||
ctx.lineTo(x + w, y + h - radius);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
||||
ctx.lineTo(x + radius, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||
if (!isVisibleForSnapshot(el)) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const x = rect.left - rootRect.left;
|
||||
const y = rect.top - rootRect.top;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const radius = parseFloat(style.borderTopLeftRadius) || 0;
|
||||
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||
|
||||
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||
if (bgAlpha > 0.01) {
|
||||
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
||||
ctx.fill();
|
||||
}
|
||||
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||
if (borderWidth > 0 && borderAlpha > 0.01) {
|
||||
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
||||
ctx.lineWidth = borderWidth;
|
||||
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
return { x, y, w, h, style };
|
||||
}
|
||||
|
||||
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return;
|
||||
let line = "";
|
||||
let lineIdx = 0;
|
||||
for (let i = 0; i < words.length; i += 1) {
|
||||
const candidate = line ? `${line} ${words[i]}` : words[i];
|
||||
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||
line = candidate;
|
||||
continue;
|
||||
}
|
||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||
lineIdx += 1;
|
||||
if (lineIdx >= maxLines) return;
|
||||
line = words[i];
|
||||
}
|
||||
if (line && lineIdx < maxLines) {
|
||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||
if (!chrome) return;
|
||||
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
|
||||
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
|
||||
if (!clean) return;
|
||||
const style = chrome.style;
|
||||
const fontSize = parseFloat(style.fontSize) || 12;
|
||||
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
|
||||
const padX = 6;
|
||||
const padY = 4;
|
||||
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
|
||||
ctx.fillStyle = style.color || "#ffffff";
|
||||
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||
ctx.textBaseline = "top";
|
||||
const lines = clean.split(/\n+/);
|
||||
let lineCursor = 0;
|
||||
for (const line of lines) {
|
||||
if (lineCursor >= maxLines) break;
|
||||
drawWrappedText(
|
||||
ctx,
|
||||
line,
|
||||
chrome.x + padX,
|
||||
chrome.y + padY + lineCursor * lineHeight,
|
||||
maxWidth,
|
||||
lineHeight,
|
||||
maxLines - lineCursor,
|
||||
);
|
||||
lineCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||
if (!isVisibleForSnapshot(axisEl)) return;
|
||||
for (const node of axisEl.children) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
if (!(node.matches("span") || node.matches("button"))) continue;
|
||||
if (!isVisibleForSnapshot(node)) continue;
|
||||
const chrome = drawElementChrome(ctx, node, rootRect);
|
||||
const text = (node.textContent || "").trim();
|
||||
if (!chrome || !text) continue;
|
||||
const style = chrome.style;
|
||||
ctx.fillStyle = style.color || "#ffffff";
|
||||
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpectrumSnapshotCanvas() {
|
||||
const rootEl = document.querySelector(".signal-visual-block");
|
||||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
|
||||
return null;
|
||||
}
|
||||
for (const renderer of [T.overviewGl, T.spectrumGl, T.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");
|
||||
out.width = Math.max(1, Math.round(rootRect.width * dpr));
|
||||
out.height = Math.max(1, Math.round(rootRect.height * dpr));
|
||||
const ctx = out.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||
|
||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||
for (const canvas of canvases) {
|
||||
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.drawImage(
|
||||
canvas,
|
||||
rect.left - rootRect.left,
|
||||
rect.top - rootRect.top,
|
||||
rect.width,
|
||||
rect.height,
|
||||
);
|
||||
}
|
||||
|
||||
// Decoder overlays over the signal view.
|
||||
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
|
||||
// replicated on canvas, so frosted-glass overlays would otherwise obscure
|
||||
// the spectrum).
|
||||
const decoderOverlayIds = [
|
||||
"ais-bar-overlay",
|
||||
"vdes-bar-overlay",
|
||||
"ft8-bar-overlay",
|
||||
"aprs-bar-overlay",
|
||||
"rds-ps-overlay",
|
||||
];
|
||||
for (const id of decoderOverlayIds) {
|
||||
const overlayEl = document.getElementById(id);
|
||||
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||
}
|
||||
|
||||
// Spectrum axis labels and bookmark chips (includes freq bar).
|
||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||
drawAxisLabels(ctx, spectrumDbAxis, rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function clickCanvasDownload(href, fileName) {
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.download = fileName;
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
requestAnimationFrame(() => a.remove());
|
||||
}
|
||||
|
||||
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) {
|
||||
T.showHint("Spectrum view not ready", 1300);
|
||||
return false;
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Register module API
|
||||
window.trx.screenshot = {
|
||||
captureSpectrumScreenshot,
|
||||
buildSpectrumSnapshotCanvas,
|
||||
saveCanvasAsPng,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
/* ── Arctic style ─────────────────────────────────────────────────────── */
|
||||
[data-style="arctic"] {
|
||||
--bg: #242933;
|
||||
--card-bg: #2e3440;
|
||||
--input-bg: #242933;
|
||||
--border: #3b4252;
|
||||
--border-light: #4c566a;
|
||||
--text: #d8dee9;
|
||||
--text-muted: #8a9ab0;
|
||||
--text-heading: #eceff4;
|
||||
--btn-bg: #3b4252;
|
||||
--btn-border: #5e6f88;
|
||||
--accent-green: #88c0d0;
|
||||
--accent-yellow: #ebcb8b;
|
||||
--accent-red: #bf616a;
|
||||
--jog-hi: #434c5e;
|
||||
--jog-lo: #3b4252;
|
||||
--jog-shadow: rgba(0,0,0,0.40);
|
||||
--jog-inset: rgba(255,255,255,0.06);
|
||||
--audio-level-bg: #2e3440;
|
||||
--audio-level-border: #4c566a;
|
||||
--audio-level-fill-start: #88c0d0;
|
||||
--audio-level-fill-end: #ebcb8b;
|
||||
--filter-bg: #3b4252;
|
||||
--filter-fg: #d8dee9;
|
||||
--filter-border: #5e6f88;
|
||||
--wavelength-fg: #7a8ea8;
|
||||
--spectrum-bg: #1e2530;
|
||||
}
|
||||
[data-style="arctic"][data-theme="light"] {
|
||||
--bg: #e5e9f0;
|
||||
--card-bg: #eceff4;
|
||||
--input-bg: #d8dee9;
|
||||
--border: #c5ccd8;
|
||||
--border-light: #a8b2c0;
|
||||
--text: #2e3440;
|
||||
--text-muted: #4c566a;
|
||||
--text-heading: #2e3440;
|
||||
--btn-bg: #d8dee9;
|
||||
--btn-border: #8fa3b8;
|
||||
--accent-green: #5e81ac;
|
||||
--accent-yellow: #c07a22;
|
||||
--accent-red: #bf616a;
|
||||
--jog-hi: #d8dee9;
|
||||
--jog-lo: #c2cbd8;
|
||||
--jog-shadow: rgba(46,52,64,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.70);
|
||||
--audio-level-bg: #d0d6e0;
|
||||
--audio-level-border: #a8b2c0;
|
||||
--audio-level-fill-start: #5e81ac;
|
||||
--audio-level-fill-end: #c07a22;
|
||||
--filter-bg: #d8dee9;
|
||||
--filter-fg: #2e3440;
|
||||
--filter-border: #8fa3b8;
|
||||
--wavelength-fg: #5a6a80;
|
||||
--spectrum-bg: #dde1e9;
|
||||
}
|
||||
|
||||
/* ── Lime style ───────────────────────────────────────────────────────── */
|
||||
[data-style="lime"] {
|
||||
--bg: #1c1c17;
|
||||
--card-bg: #272822;
|
||||
--input-bg: #1c1c17;
|
||||
--border: #3e3d32;
|
||||
--border-light: #5c5c45;
|
||||
--text: #f8f8f2;
|
||||
--text-muted: #908980;
|
||||
--text-heading: #f8f8f2;
|
||||
--btn-bg: #3e3d32;
|
||||
--btn-border: #75715e;
|
||||
--accent-green: #a6e22e;
|
||||
--accent-yellow: #e6db74;
|
||||
--accent-red: #f92672;
|
||||
--jog-hi: #49483e;
|
||||
--jog-lo: #3e3d32;
|
||||
--jog-shadow: rgba(0,0,0,0.45);
|
||||
--jog-inset: rgba(255,255,255,0.05);
|
||||
--audio-level-bg: #272822;
|
||||
--audio-level-border: #5c5c45;
|
||||
--audio-level-fill-start: #a6e22e;
|
||||
--audio-level-fill-end: #e6db74;
|
||||
--filter-bg: #3e3d32;
|
||||
--filter-fg: #f8f8f2;
|
||||
--filter-border: #75715e;
|
||||
--wavelength-fg: #9c8f78;
|
||||
--spectrum-bg: #181815;
|
||||
}
|
||||
[data-style="lime"][data-theme="light"] {
|
||||
--bg: #f5f0e4;
|
||||
--card-bg: #fdf9f2;
|
||||
--input-bg: #ede8d8;
|
||||
--border: #d8d0bb;
|
||||
--border-light: #c0b89e;
|
||||
--text: #272822;
|
||||
--text-muted: #6e6a56;
|
||||
--text-heading: #272822;
|
||||
--btn-bg: #ede8d8;
|
||||
--btn-border: #b0a888;
|
||||
--accent-green: #5f8700;
|
||||
--accent-yellow: #9a7200;
|
||||
--accent-red: #c60052;
|
||||
--jog-hi: #ede8d8;
|
||||
--jog-lo: #ddd8c8;
|
||||
--jog-shadow: rgba(39,40,34,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.75);
|
||||
--audio-level-bg: #ede8d8;
|
||||
--audio-level-border: #c0b89e;
|
||||
--audio-level-fill-start: #5f8700;
|
||||
--audio-level-fill-end: #9a7200;
|
||||
--filter-bg: #ede8d8;
|
||||
--filter-fg: #272822;
|
||||
--filter-border: #b0a888;
|
||||
--wavelength-fg: #7a7260;
|
||||
--spectrum-bg: #ede8d8;
|
||||
}
|
||||
|
||||
/* ── Contrast style ───────────────────────────────────────────────────── */
|
||||
[data-style="contrast"] {
|
||||
--bg: #000000;
|
||||
--card-bg: #0a0a0a;
|
||||
--input-bg: #111111;
|
||||
--border: #333333;
|
||||
--border-light: #555555;
|
||||
--text: #ffffff;
|
||||
--text-muted: #bbbbbb;
|
||||
--text-heading: #ffffff;
|
||||
--btn-bg: #1a1a1a;
|
||||
--btn-border: #666666;
|
||||
--accent-green: #00ff88;
|
||||
--accent-yellow: #ffcc00;
|
||||
--accent-red: #ff3344;
|
||||
--jog-hi: #2a2a2a;
|
||||
--jog-lo: #1a1a1a;
|
||||
--jog-shadow: rgba(0,0,0,0.60);
|
||||
--jog-inset: rgba(255,255,255,0.08);
|
||||
--audio-level-bg: #111111;
|
||||
--audio-level-border: #555555;
|
||||
--audio-level-fill-start: #00ff88;
|
||||
--audio-level-fill-end: #ffcc00;
|
||||
--filter-bg: #1a1a1a;
|
||||
--filter-fg: #ffffff;
|
||||
--filter-border: #666666;
|
||||
--wavelength-fg: #aaaaaa;
|
||||
--spectrum-bg: #000000;
|
||||
}
|
||||
[data-style="contrast"][data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--card-bg: #f4f4f4;
|
||||
--input-bg: #e8e8e8;
|
||||
--border: #cccccc;
|
||||
--border-light: #999999;
|
||||
--text: #000000;
|
||||
--text-muted: #333333;
|
||||
--text-heading: #000000;
|
||||
--btn-bg: #e0e0e0;
|
||||
--btn-border: #777777;
|
||||
--accent-green: #005cc5;
|
||||
--accent-yellow: #cc5500;
|
||||
--accent-red: #cc0000;
|
||||
--jog-hi: #e0e0e0;
|
||||
--jog-lo: #cccccc;
|
||||
--jog-shadow: rgba(0,0,0,0.25);
|
||||
--jog-inset: rgba(255,255,255,0.80);
|
||||
--audio-level-bg: #e8e8e8;
|
||||
--audio-level-border: #999999;
|
||||
--audio-level-fill-start: #005cc5;
|
||||
--audio-level-fill-end: #cc5500;
|
||||
--filter-bg: #e8e8e8;
|
||||
--filter-fg: #000000;
|
||||
--filter-border: #999999;
|
||||
--wavelength-fg: #444444;
|
||||
--spectrum-bg: #f4f4f4;
|
||||
}
|
||||
|
||||
/* ── Neon Disco style ─────────────────────────────────────────────────── */
|
||||
[data-style="neon-disco"] {
|
||||
--bg: #0d0015;
|
||||
--card-bg: #180026;
|
||||
--input-bg: #100018;
|
||||
--border: #3d0060;
|
||||
--border-light: #7700bb;
|
||||
--text: #f5e0ff;
|
||||
--text-muted: #b070d8;
|
||||
--text-heading: #fce8ff;
|
||||
--btn-bg: #2a0042;
|
||||
--btn-border: #9900dd;
|
||||
--accent-green: #ff10e0;
|
||||
--accent-yellow: #39ff14;
|
||||
--accent-red: #ff1460;
|
||||
--jog-hi: #360058;
|
||||
--jog-lo: #280042;
|
||||
--jog-shadow: rgba(0,0,0,0.65);
|
||||
--jog-inset: rgba(255,16,224,0.08);
|
||||
--audio-level-bg: #180026;
|
||||
--audio-level-border: #7700bb;
|
||||
--audio-level-fill-start: #ff10e0;
|
||||
--audio-level-fill-end: #39ff14;
|
||||
--filter-bg: #2a0042;
|
||||
--filter-fg: #f5e0ff;
|
||||
--filter-border: #9900dd;
|
||||
--wavelength-fg: #9055b8;
|
||||
--spectrum-bg: #090010;
|
||||
}
|
||||
[data-style="neon-disco"][data-theme="light"] {
|
||||
--bg: #faeeff;
|
||||
--card-bg: #fff4ff;
|
||||
--input-bg: #f2e0ff;
|
||||
--border: #dda8f5;
|
||||
--border-light: #cc80e8;
|
||||
--text: #1a0030;
|
||||
--text-muted: #7a30a0;
|
||||
--text-heading: #1a0030;
|
||||
--btn-bg: #f0d8ff;
|
||||
--btn-border: #bb80dd;
|
||||
--accent-green: #cc00a8;
|
||||
--accent-yellow: #1f8800;
|
||||
--accent-red: #cc0044;
|
||||
--jog-hi: #f0d8ff;
|
||||
--jog-lo: #e2c8f5;
|
||||
--jog-shadow: rgba(60,0,100,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.72);
|
||||
--audio-level-bg: #f0d8ff;
|
||||
--audio-level-border: #cc80e8;
|
||||
--audio-level-fill-start: #cc00a8;
|
||||
--audio-level-fill-end: #1f8800;
|
||||
--filter-bg: #f0d8ff;
|
||||
--filter-fg: #1a0030;
|
||||
--filter-border: #bb80dd;
|
||||
--wavelength-fg: #7030a0;
|
||||
--spectrum-bg: #f0d8ff;
|
||||
}
|
||||
/* ── Donald style ─────────────────────────────────────────────────────── */
|
||||
[data-style="golden-rain"] {
|
||||
--bg: #100c06;
|
||||
--card-bg: #1a1209;
|
||||
--input-bg: #140f08;
|
||||
--border: #3f2d18;
|
||||
--border-light: #6d4e23;
|
||||
--text: #f3e4bf;
|
||||
--text-muted: #aa9062;
|
||||
--text-heading: #fff0ca;
|
||||
--btn-bg: #2a1c0d;
|
||||
--btn-border: #7c5928;
|
||||
--accent-green: #dfac48;
|
||||
--accent-yellow: #f4cd74;
|
||||
--accent-red: #cf7d32;
|
||||
--jog-hi: #392610;
|
||||
--jog-lo: #24170b;
|
||||
--jog-shadow: rgba(0,0,0,0.64);
|
||||
--jog-inset: rgba(255,219,138,0.06);
|
||||
--audio-level-bg: #1c130a;
|
||||
--audio-level-border: #6d4e23;
|
||||
--audio-level-fill-start: #dfac48;
|
||||
--audio-level-fill-end: #f4cd74;
|
||||
--filter-bg: #2b1d0f;
|
||||
--filter-fg: #f3e4bf;
|
||||
--filter-border: #7c5928;
|
||||
--wavelength-fg: #ab8b52;
|
||||
--spectrum-bg: #120d07;
|
||||
}
|
||||
[data-style="golden-rain"][data-theme="light"] {
|
||||
--bg: #f7efdd;
|
||||
--card-bg: #fff9ec;
|
||||
--input-bg: #f0e3c6;
|
||||
--border: #d4bc8a;
|
||||
--border-light: #b99243;
|
||||
--text: #3f2c10;
|
||||
--text-muted: #7f6640;
|
||||
--text-heading: #3a2609;
|
||||
--btn-bg: #f0e3c6;
|
||||
--btn-border: #b99243;
|
||||
--accent-green: #a96d00;
|
||||
--accent-yellow: #c88a16;
|
||||
--accent-red: #b65316;
|
||||
--jog-hi: #f2e5c8;
|
||||
--jog-lo: #e3d1a8;
|
||||
--jog-shadow: rgba(82,55,14,0.16);
|
||||
--jog-inset: rgba(255,255,255,0.76);
|
||||
--audio-level-bg: #f0e3c6;
|
||||
--audio-level-border: #c5a15d;
|
||||
--audio-level-fill-start: #a96d00;
|
||||
--audio-level-fill-end: #d4a13a;
|
||||
--filter-bg: #f0e3c6;
|
||||
--filter-fg: #3f2c10;
|
||||
--filter-border: #b99243;
|
||||
--wavelength-fg: #87663a;
|
||||
--spectrum-bg: #f5ecd9;
|
||||
}
|
||||
|
||||
/* ── Amber style ──────────────────────────────────────────────────────── */
|
||||
[data-style="amber"] {
|
||||
--bg: #120706;
|
||||
--card-bg: #1b0c0a;
|
||||
--input-bg: #180907;
|
||||
--border: #4c1a12;
|
||||
--border-light: #7a2e1a;
|
||||
--text: #ffe7d2;
|
||||
--text-muted: #c78361;
|
||||
--text-heading: #fff3e7;
|
||||
--btn-bg: #2c110d;
|
||||
--btn-border: #8f3a20;
|
||||
--accent-green: #ff6f1f;
|
||||
--accent-yellow: #ffb347;
|
||||
--accent-red: #ff4a24;
|
||||
--jog-hi: #381510;
|
||||
--jog-lo: #24100c;
|
||||
--jog-shadow: rgba(0,0,0,0.62);
|
||||
--jog-inset: rgba(255,164,76,0.07);
|
||||
--audio-level-bg: #1f0d0a;
|
||||
--audio-level-border: #7a2e1a;
|
||||
--audio-level-fill-start: #ff4a24;
|
||||
--audio-level-fill-end: #ffb347;
|
||||
--filter-bg: #2b120d;
|
||||
--filter-fg: #ffe7d2;
|
||||
--filter-border: #8f3a20;
|
||||
--wavelength-fg: #d38d6a;
|
||||
--spectrum-bg: #140907;
|
||||
}
|
||||
[data-style="amber"][data-theme="light"] {
|
||||
--bg: #fff3ea;
|
||||
--card-bg: #fff7f0;
|
||||
--input-bg: #ffe9da;
|
||||
--border: #efc7b1;
|
||||
--border-light: #d9a487;
|
||||
--text: #42180d;
|
||||
--text-muted: #8a4b31;
|
||||
--text-heading: #2f120a;
|
||||
--btn-bg: #ffe2cf;
|
||||
--btn-border: #cc8563;
|
||||
--accent-green: #d24c12;
|
||||
--accent-yellow: #d88400;
|
||||
--accent-red: #c53114;
|
||||
--jog-hi: #ffe2cf;
|
||||
--jog-lo: #ffd5bc;
|
||||
--jog-shadow: rgba(108,44,15,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.72);
|
||||
--audio-level-bg: #ffe7d7;
|
||||
--audio-level-border: #d9a487;
|
||||
--audio-level-fill-start: #c53114;
|
||||
--audio-level-fill-end: #d88400;
|
||||
--filter-bg: #ffe2cf;
|
||||
--filter-fg: #42180d;
|
||||
--filter-border: #cc8563;
|
||||
--wavelength-fg: #9a5a3a;
|
||||
--spectrum-bg: #fff0e4;
|
||||
}
|
||||
|
||||
/* ── Fire style ───────────────────────────────────────────────────────── */
|
||||
[data-style="fire"] {
|
||||
--bg: #140406;
|
||||
--card-bg: #1d0708;
|
||||
--input-bg: #1a0607;
|
||||
--border: #551015;
|
||||
--border-light: #8f1f26;
|
||||
--text: #ffe6df;
|
||||
--text-muted: #cf8d82;
|
||||
--text-heading: #fff4ef;
|
||||
--btn-bg: #2d0c0d;
|
||||
--btn-border: #9d262b;
|
||||
--accent-green: #d13a32;
|
||||
--accent-yellow: #ff6a3d;
|
||||
--accent-red: #c10f1f;
|
||||
--jog-hi: #390f11;
|
||||
--jog-lo: #25090b;
|
||||
--jog-shadow: rgba(0,0,0,0.64);
|
||||
--jog-inset: rgba(255,120,100,0.06);
|
||||
--audio-level-bg: #22090b;
|
||||
--audio-level-border: #8f1f26;
|
||||
--audio-level-fill-start: #c10f1f;
|
||||
--audio-level-fill-end: #ff6a3d;
|
||||
--filter-bg: #2d0c0d;
|
||||
--filter-fg: #ffe6df;
|
||||
--filter-border: #9d262b;
|
||||
--wavelength-fg: #d78d78;
|
||||
--spectrum-bg: #150508;
|
||||
}
|
||||
[data-style="fire"][data-theme="light"] {
|
||||
--bg: #fdf0ea;
|
||||
--card-bg: #fff6f1;
|
||||
--input-bg: #ffe5db;
|
||||
--border: #e9b8aa;
|
||||
--border-light: #d27c66;
|
||||
--text: #4a110d;
|
||||
--text-muted: #8a493f;
|
||||
--text-heading: #340b08;
|
||||
--btn-bg: #ffd9cc;
|
||||
--btn-border: #c85b47;
|
||||
--accent-green: #ba2d24;
|
||||
--accent-yellow: #d95518;
|
||||
--accent-red: #a80f1c;
|
||||
--jog-hi: #ffd9cc;
|
||||
--jog-lo: #ffcab9;
|
||||
--jog-shadow: rgba(110,35,20,0.18);
|
||||
--jog-inset: rgba(255,255,255,0.74);
|
||||
--audio-level-bg: #ffe2d7;
|
||||
--audio-level-border: #d27c66;
|
||||
--audio-level-fill-start: #a80f1c;
|
||||
--audio-level-fill-end: #d95518;
|
||||
--filter-bg: #ffd9cc;
|
||||
--filter-fg: #4a110d;
|
||||
--filter-border: #c85b47;
|
||||
--wavelength-fg: #9d5547;
|
||||
--spectrum-bg: #ffede5;
|
||||
}
|
||||
|
||||
/* ── Phosphor style ───────────────────────────────────────────────────── */
|
||||
/* Classic green-phosphor CRT terminal aesthetic */
|
||||
[data-style="phosphor"] {
|
||||
--bg: #030a03;
|
||||
--card-bg: #060e06;
|
||||
--input-bg: #030a03;
|
||||
--border: #0f2e0f;
|
||||
--border-light: #1a4a1a;
|
||||
--text: #a8e6a8;
|
||||
--text-muted: #5a9a5a;
|
||||
--text-heading: #c8f0c8;
|
||||
--btn-bg: #0a1e0a;
|
||||
--btn-border: #1e4a1e;
|
||||
--accent-green: #39ff14;
|
||||
--accent-yellow: #b8f060;
|
||||
--accent-red: #ff4444;
|
||||
--jog-hi: #0e2a0e;
|
||||
--jog-lo: #081808;
|
||||
--jog-shadow: rgba(0,0,0,0.65);
|
||||
--jog-inset: rgba(57,255,20,0.07);
|
||||
--audio-level-bg: #060e06;
|
||||
--audio-level-border: #1a4a1a;
|
||||
--audio-level-fill-start: #39ff14;
|
||||
--audio-level-fill-end: #b8f060;
|
||||
--filter-bg: #0a1e0a;
|
||||
--filter-fg: #a8e6a8;
|
||||
--filter-border: #1e4a1e;
|
||||
--wavelength-fg: #4a8a4a;
|
||||
--spectrum-bg: #010501;
|
||||
}
|
||||
[data-style="phosphor"] #freq {
|
||||
color: #39ff14;
|
||||
text-shadow: 0 0 8px rgba(57,255,20,0.55), 0 0 20px rgba(57,255,20,0.2);
|
||||
}
|
||||
[data-style="phosphor"] .signal-bar-fill,
|
||||
[data-style="phosphor"] .meter-fill {
|
||||
background: linear-gradient(90deg, #39ff14, #b8f060);
|
||||
box-shadow: 0 0 6px rgba(57,255,20,0.45);
|
||||
}
|
||||
[data-style="phosphor"][data-theme="light"] {
|
||||
--bg: #e8f5e8;
|
||||
--card-bg: #f0faf0;
|
||||
--input-bg: #dff0df;
|
||||
--border: #b0d8b0;
|
||||
--border-light: #80c080;
|
||||
--text: #0a2a0a;
|
||||
--text-muted: #2a6a2a;
|
||||
--text-heading: #062006;
|
||||
--btn-bg: #d0ebd0;
|
||||
--btn-border: #4a8a4a;
|
||||
--accent-green: #1a7a1a;
|
||||
--accent-yellow: #4a8a00;
|
||||
--accent-red: #cc2222;
|
||||
--jog-hi: #d0ebd0;
|
||||
--jog-lo: #bcdabc;
|
||||
--jog-shadow: rgba(10,42,10,0.15);
|
||||
--jog-inset: rgba(255,255,255,0.72);
|
||||
--audio-level-bg: #d8edd8;
|
||||
--audio-level-border: #80c080;
|
||||
--audio-level-fill-start: #1a7a1a;
|
||||
--audio-level-fill-end: #4a8a00;
|
||||
--filter-bg: #d0ebd0;
|
||||
--filter-fg: #0a2a0a;
|
||||
--filter-border: #4a8a4a;
|
||||
--wavelength-fg: #3a7a3a;
|
||||
--spectrum-bg: #e0f0e0;
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,535 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
(function initTrxWebGl(global) {
|
||||
"use strict";
|
||||
|
||||
const cssColorCache = new Map();
|
||||
let cssColorProbe = null;
|
||||
|
||||
function clearCssColorCache() {
|
||||
cssColorCache.clear();
|
||||
}
|
||||
|
||||
function ensureCssColorProbe() {
|
||||
if (cssColorProbe) return cssColorProbe;
|
||||
const el = document.createElement("span");
|
||||
el.style.position = "absolute";
|
||||
el.style.left = "-9999px";
|
||||
el.style.top = "-9999px";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.opacity = "0";
|
||||
document.body.appendChild(el);
|
||||
cssColorProbe = el;
|
||||
return cssColorProbe;
|
||||
}
|
||||
|
||||
function parseRgbString(value) {
|
||||
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
|
||||
if (!m) return null;
|
||||
const parts = m[1].split(",").map((p) => p.trim());
|
||||
if (parts.length < 3) return null;
|
||||
const r = Number(parts[0]);
|
||||
const g = Number(parts[1]);
|
||||
const b = Number(parts[2]);
|
||||
const a = parts.length > 3 ? Number(parts[3]) : 1;
|
||||
if (![r, g, b, a].every(Number.isFinite)) return null;
|
||||
return [
|
||||
Math.max(0, Math.min(1, r / 255)),
|
||||
Math.max(0, Math.min(1, g / 255)),
|
||||
Math.max(0, Math.min(1, b / 255)),
|
||||
Math.max(0, Math.min(1, a)),
|
||||
];
|
||||
}
|
||||
|
||||
function parseHexColor(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
|
||||
let hex = raw.slice(1);
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
hex = hex.split("").map((ch) => ch + ch).join("");
|
||||
}
|
||||
if (!(hex.length === 6 || hex.length === 8)) return null;
|
||||
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return [r, g, b, a];
|
||||
}
|
||||
|
||||
function parseCssColor(value) {
|
||||
const key = String(value ?? "");
|
||||
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
|
||||
|
||||
let parsed = parseHexColor(key) || parseRgbString(key);
|
||||
if (!parsed) {
|
||||
const probe = ensureCssColorProbe();
|
||||
probe.style.color = "";
|
||||
probe.style.color = key;
|
||||
const computed = getComputedStyle(probe).color;
|
||||
parsed = parseRgbString(computed) || [0, 0, 0, 1];
|
||||
}
|
||||
cssColorCache.set(key, parsed.slice());
|
||||
return parsed.slice();
|
||||
}
|
||||
|
||||
function hslToRgba(h, s, l, a = 1) {
|
||||
const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
|
||||
const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
|
||||
const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
|
||||
|
||||
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
|
||||
const p = 2 * lig - q;
|
||||
const hueToRgb = (t) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
|
||||
const g = sat === 0 ? lig : hueToRgb(hue);
|
||||
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
|
||||
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
|
||||
}
|
||||
|
||||
function normalizeColor(input, alphaMul = 1) {
|
||||
let rgba;
|
||||
if (Array.isArray(input)) {
|
||||
const arr = input.map((v) => Number(v));
|
||||
if (arr.length >= 4) {
|
||||
rgba = [arr[0], arr[1], arr[2], arr[3]];
|
||||
} else {
|
||||
rgba = [0, 0, 0, 1];
|
||||
}
|
||||
} else if (typeof input === "string") {
|
||||
rgba = parseCssColor(input);
|
||||
} else if (input && typeof input === "object") {
|
||||
rgba = [
|
||||
Number(input.r) || 0,
|
||||
Number(input.g) || 0,
|
||||
Number(input.b) || 0,
|
||||
Number(input.a ?? 1),
|
||||
];
|
||||
} else {
|
||||
rgba = [0, 0, 0, 1];
|
||||
}
|
||||
const out = [
|
||||
Math.max(0, Math.min(1, rgba[0])),
|
||||
Math.max(0, Math.min(1, rgba[1])),
|
||||
Math.max(0, Math.min(1, rgba[2])),
|
||||
Math.max(0, Math.min(1, rgba[3] * alphaMul)),
|
||||
];
|
||||
return out;
|
||||
}
|
||||
|
||||
function compileShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(shader) || "shader compile error";
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(log);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl, vertexSrc, fragmentSrc) {
|
||||
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
|
||||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const log = gl.getProgramInfoLog(program) || "program link error";
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(log);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
function pushColoredVertex(target, x, y, rgba) {
|
||||
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
}
|
||||
|
||||
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (!(len > 0.0001)) return;
|
||||
const nx = (-dy / len) * halfW;
|
||||
const ny = (dx / len) * halfW;
|
||||
|
||||
const ax = x0 - nx, ay = y0 - ny;
|
||||
const bx = x0 + nx, by = y0 + ny;
|
||||
const cx = x1 + nx, cy = y1 + ny;
|
||||
const dx2 = x1 - nx, dy2 = y1 - ny;
|
||||
|
||||
pushColoredVertex(out, ax, ay, rgba);
|
||||
pushColoredVertex(out, bx, by, rgba);
|
||||
pushColoredVertex(out, cx, cy, rgba);
|
||||
|
||||
pushColoredVertex(out, ax, ay, rgba);
|
||||
pushColoredVertex(out, cx, cy, rgba);
|
||||
pushColoredVertex(out, dx2, dy2, rgba);
|
||||
}
|
||||
|
||||
class TrxWebGlRenderer {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.options = { alpha: true, premultipliedAlpha: false, ...options };
|
||||
this.gl =
|
||||
canvas?.getContext("webgl", this.options) ||
|
||||
canvas?.getContext("experimental-webgl", this.options) ||
|
||||
null;
|
||||
this.ready = !!this.gl;
|
||||
this.textures = new Map();
|
||||
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
|
||||
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
|
||||
this._colorScratch = new Float32Array(4096 * 6); // grows as needed
|
||||
this._colorGpuSize = 0; // current GPU buffer size (floats)
|
||||
this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts × (xy+uv)
|
||||
if (!this.ready) return;
|
||||
|
||||
const gl = this.gl;
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.disable(gl.CULL_FACE);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
const colorVertexSrc =
|
||||
"attribute vec2 a_pos;\n" +
|
||||
"attribute vec4 a_color;\n" +
|
||||
"uniform vec2 u_resolution;\n" +
|
||||
"varying vec4 v_color;\n" +
|
||||
"void main() {\n" +
|
||||
" vec2 zeroToOne = a_pos / u_resolution;\n" +
|
||||
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
|
||||
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
|
||||
" v_color = a_color;\n" +
|
||||
"}\n";
|
||||
const colorFragmentSrc =
|
||||
"precision mediump float;\n" +
|
||||
"varying vec4 v_color;\n" +
|
||||
"void main() {\n" +
|
||||
" gl_FragColor = v_color;\n" +
|
||||
"}\n";
|
||||
|
||||
const textureVertexSrc =
|
||||
"attribute vec2 a_pos;\n" +
|
||||
"attribute vec2 a_uv;\n" +
|
||||
"uniform vec2 u_resolution;\n" +
|
||||
"varying vec2 v_uv;\n" +
|
||||
"void main() {\n" +
|
||||
" vec2 zeroToOne = a_pos / u_resolution;\n" +
|
||||
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
|
||||
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
|
||||
" v_uv = a_uv;\n" +
|
||||
"}\n";
|
||||
const textureFragmentSrc =
|
||||
"precision mediump float;\n" +
|
||||
"varying vec2 v_uv;\n" +
|
||||
"uniform sampler2D u_tex;\n" +
|
||||
"uniform float u_alpha;\n" +
|
||||
"void main() {\n" +
|
||||
" vec4 c = texture2D(u_tex, v_uv);\n" +
|
||||
" gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" +
|
||||
"}\n";
|
||||
|
||||
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
|
||||
this.colorBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||
this._colorGpuSize = this._colorScratch.length;
|
||||
this.colorLoc = {
|
||||
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
|
||||
color: gl.getAttribLocation(this.colorProgram, "a_color"),
|
||||
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"),
|
||||
};
|
||||
|
||||
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
|
||||
this.textureBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
|
||||
this.textureLoc = {
|
||||
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
|
||||
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
|
||||
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
|
||||
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
|
||||
tex: gl.getUniformLocation(this.textureProgram, "u_tex"),
|
||||
};
|
||||
}
|
||||
|
||||
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
|
||||
if (!this.ready) return false;
|
||||
const nextW = Math.max(1, Math.round(cssWidth * dpr));
|
||||
const nextH = Math.max(1, Math.round(cssHeight * dpr));
|
||||
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
|
||||
if (changed) {
|
||||
this.canvas.width = nextW;
|
||||
this.canvas.height = nextH;
|
||||
}
|
||||
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
return changed;
|
||||
}
|
||||
|
||||
clear(color) {
|
||||
if (!this.ready) return;
|
||||
const gl = this.gl;
|
||||
const rgba = normalizeColor(color);
|
||||
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
drawTriangles(vertices) {
|
||||
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawTriangleStrip(vertices) {
|
||||
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
|
||||
}
|
||||
|
||||
_drawColorGeometry(vertices, mode) {
|
||||
if (!this.ready || !vertices || vertices.length === 0) return;
|
||||
const gl = this.gl;
|
||||
const count = vertices.length;
|
||||
|
||||
// Grow scratch buffer if needed (doubles each time to amortise copies).
|
||||
if (count > this._colorScratch.length) {
|
||||
let newLen = this._colorScratch.length;
|
||||
while (newLen < count) newLen *= 2;
|
||||
this._colorScratch = new Float32Array(newLen);
|
||||
}
|
||||
|
||||
// Copy into scratch (set() is a fast typed memcpy; avoids new allocation).
|
||||
this._colorScratch.set(vertices);
|
||||
const view = this._colorScratch.subarray(0, count);
|
||||
|
||||
gl.useProgram(this.colorProgram);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||
|
||||
// Only reallocate the GPU buffer when it is too small; otherwise use
|
||||
// bufferSubData which avoids a GPU reallocation (Safari is sensitive to this).
|
||||
if (count > this._colorGpuSize) {
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||
this._colorGpuSize = this._colorScratch.length;
|
||||
} else {
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
|
||||
}
|
||||
|
||||
gl.enableVertexAttribArray(this.colorLoc.pos);
|
||||
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
|
||||
gl.enableVertexAttribArray(this.colorLoc.color);
|
||||
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
|
||||
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.drawArrays(mode, 0, count / 6);
|
||||
}
|
||||
|
||||
fillRect(x, y, w, h, color) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const v = [];
|
||||
pushColoredVertex(v, x, y, rgba);
|
||||
pushColoredVertex(v, x + w, y, rgba);
|
||||
pushColoredVertex(v, x + w, y + h, rgba);
|
||||
pushColoredVertex(v, x, y, rgba);
|
||||
pushColoredVertex(v, x + w, y + h, rgba);
|
||||
pushColoredVertex(v, x, y + h, rgba);
|
||||
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const tl = normalizeColor(colorTL);
|
||||
const tr = normalizeColor(colorTR);
|
||||
const br = normalizeColor(colorBR);
|
||||
const bl = normalizeColor(colorBL);
|
||||
const v = [];
|
||||
pushColoredVertex(v, x, y, tl);
|
||||
pushColoredVertex(v, x + w, y, tr);
|
||||
pushColoredVertex(v, x + w, y + h, br);
|
||||
pushColoredVertex(v, x, y, tl);
|
||||
pushColoredVertex(v, x + w, y + h, br);
|
||||
pushColoredVertex(v, x, y + h, bl);
|
||||
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawPolyline(points, color, width = 1) {
|
||||
if (!Array.isArray(points) || points.length < 4) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||
const verts = [];
|
||||
for (let i = 0; i < points.length - 2; i += 2) {
|
||||
segmentToQuadVertices(
|
||||
verts,
|
||||
points[i], points[i + 1],
|
||||
points[i + 2], points[i + 3],
|
||||
halfW,
|
||||
rgba,
|
||||
);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawSegments(segments, color, width = 1) {
|
||||
if (!Array.isArray(segments) || segments.length < 4) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||
const verts = [];
|
||||
for (let i = 0; i < segments.length - 3; i += 4) {
|
||||
segmentToQuadVertices(
|
||||
verts,
|
||||
segments[i], segments[i + 1],
|
||||
segments[i + 2], segments[i + 3],
|
||||
halfW,
|
||||
rgba,
|
||||
);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawFilledArea(points, baselineY, color) {
|
||||
if (!Array.isArray(points) || points.length < 4) return;
|
||||
const rgba = normalizeColor(color);
|
||||
const verts = [];
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
pushColoredVertex(verts, points[i], baselineY, rgba);
|
||||
pushColoredVertex(verts, points[i], points[i + 1], rgba);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
|
||||
}
|
||||
|
||||
drawPoints(points, size, color) {
|
||||
if (!Array.isArray(points) || points.length < 2) return;
|
||||
const radius = Math.max(1, Number(size) || 1);
|
||||
const rgba = normalizeColor(color);
|
||||
const verts = [];
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
const x = points[i] - radius;
|
||||
const y = points[i + 1] - radius;
|
||||
const w = radius * 2;
|
||||
const h = radius * 2;
|
||||
pushColoredVertex(verts, x, y, rgba);
|
||||
pushColoredVertex(verts, x + w, y, rgba);
|
||||
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||
pushColoredVertex(verts, x, y, rgba);
|
||||
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||
pushColoredVertex(verts, x, y + h, rgba);
|
||||
}
|
||||
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||
}
|
||||
|
||||
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
|
||||
const dash = Math.max(1, Number(dashLen) || 1);
|
||||
const gap = Math.max(1, Number(gapLen) || 1);
|
||||
const top = Math.min(y0, y1);
|
||||
const bottom = Math.max(y0, y1);
|
||||
const segments = [];
|
||||
for (let y = top; y < bottom; y += dash + gap) {
|
||||
const segEnd = Math.min(bottom, y + dash);
|
||||
segments.push(x, y, x, segEnd);
|
||||
}
|
||||
this.drawSegments(segments, color, width);
|
||||
}
|
||||
|
||||
uploadRgbaTexture(name, width, height, data, filter = "linear") {
|
||||
if (!this.ready || !name || !data) return null;
|
||||
const gl = this.gl;
|
||||
let entry = this.textures.get(name);
|
||||
if (!entry) {
|
||||
const texture = gl.createTexture();
|
||||
entry = { texture, width: 0, height: 0 };
|
||||
this.textures.set(name, entry);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
if (entry.width !== width || entry.height !== height) {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
entry.width = width;
|
||||
entry.height = height;
|
||||
} else {
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
}
|
||||
return entry.texture;
|
||||
}
|
||||
|
||||
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
|
||||
if (!this.ready || !name || w <= 0 || h <= 0) return;
|
||||
const entry = this.textures.get(name);
|
||||
if (!entry) return;
|
||||
const gl = this.gl;
|
||||
const s = this._texScratch;
|
||||
const x2 = x + w, y2 = y + h;
|
||||
if (flipY) {
|
||||
s[0]=x; s[1]=y; s[2]=0; s[3]=1;
|
||||
s[4]=x2; s[5]=y; s[6]=1; s[7]=1;
|
||||
s[8]=x2; s[9]=y2; s[10]=1;s[11]=0;
|
||||
s[12]=x; s[13]=y; s[14]=0;s[15]=1;
|
||||
s[16]=x2;s[17]=y2;s[18]=1;s[19]=0;
|
||||
s[20]=x; s[21]=y2;s[22]=0;s[23]=0;
|
||||
} else {
|
||||
s[0]=x; s[1]=y; s[2]=0; s[3]=0;
|
||||
s[4]=x2; s[5]=y; s[6]=1; s[7]=0;
|
||||
s[8]=x2; s[9]=y2; s[10]=1;s[11]=1;
|
||||
s[12]=x; s[13]=y; s[14]=0;s[15]=0;
|
||||
s[16]=x2;s[17]=y2;s[18]=1;s[19]=1;
|
||||
s[20]=x; s[21]=y2;s[22]=0;s[23]=1;
|
||||
}
|
||||
gl.useProgram(this.textureProgram);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
|
||||
gl.enableVertexAttribArray(this.textureLoc.pos);
|
||||
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.enableVertexAttribArray(this.textureLoc.uv);
|
||||
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
|
||||
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
|
||||
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||
gl.uniform1i(this.textureLoc.tex, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderer(canvas, options) {
|
||||
return new TrxWebGlRenderer(canvas, options);
|
||||
}
|
||||
|
||||
global.trxParseCssColor = parseCssColor;
|
||||
global.trxHslToRgba = hslToRgba;
|
||||
global.createTrxWebGlRenderer = createRenderer;
|
||||
global.trxClearCssColorCache = clearCssColorCache;
|
||||
})(window);
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let mut y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = mp + if mp < 10 { 3 } else { -9 };
|
||||
y += if m <= 2 { 1 } else { 0 };
|
||||
(y as i32, m as u32, d as u32)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(d) => d.as_secs() as i64,
|
||||
Err(_) => 0,
|
||||
};
|
||||
let (y, m, d) = utc_ymd_from_unix_secs(secs);
|
||||
println!("cargo:rustc-env=TRX_CLIENT_BUILD_DATE={y:04}-{m:02}-{d:02}");
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Static asset serving endpoints (HTML pages, JS, CSS, favicon, logo).
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::web;
|
||||
use actix_web::{get, HttpRequest, HttpResponse, Responder};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
|
||||
use crate::server::status;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compressed asset caches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! define_gz_cache {
|
||||
($fn_name:ident, $src:expr, $asset_name:literal) => {
|
||||
fn $fn_name() -> &'static GzCacheEntry {
|
||||
static CACHE: OnceLock<GzCacheEntry> = OnceLock::new();
|
||||
CACHE.get_or_init(|| gz_cache_entry($src.as_bytes(), $asset_name))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_gz_cache!(gz_index_html, status::index_html(), "index.html");
|
||||
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
|
||||
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
|
||||
define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
|
||||
define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js");
|
||||
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
|
||||
define_gz_cache!(
|
||||
gz_decode_history_worker_js,
|
||||
status::DECODE_HISTORY_WORKER_JS,
|
||||
"decode-history-worker.js"
|
||||
);
|
||||
define_gz_cache!(
|
||||
gz_webgl_renderer_js,
|
||||
status::WEBGL_RENDERER_JS,
|
||||
"webgl-renderer.js"
|
||||
);
|
||||
define_gz_cache!(
|
||||
gz_leaflet_ais_tracksymbol_js,
|
||||
status::LEAFLET_AIS_TRACKSYMBOL_JS,
|
||||
"leaflet-ais-tracksymbol.js"
|
||||
);
|
||||
define_gz_cache!(gz_ais_js, status::AIS_JS, "ais.js");
|
||||
define_gz_cache!(gz_vdes_js, status::VDES_JS, "vdes.js");
|
||||
define_gz_cache!(gz_aprs_js, status::APRS_JS, "aprs.js");
|
||||
define_gz_cache!(gz_hf_aprs_js, status::HF_APRS_JS, "hf-aprs.js");
|
||||
define_gz_cache!(gz_ft8_js, status::FT8_JS, "ft8.js");
|
||||
define_gz_cache!(gz_ft4_js, status::FT4_JS, "ft4.js");
|
||||
define_gz_cache!(gz_ft2_js, status::FT2_JS, "ft2.js");
|
||||
define_gz_cache!(gz_wspr_js, status::WSPR_JS, "wspr.js");
|
||||
define_gz_cache!(gz_cw_js, status::CW_JS, "cw.js");
|
||||
define_gz_cache!(gz_sat_js, status::SAT_JS, "sat.js");
|
||||
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
|
||||
define_gz_cache!(gz_bookmarks_js, status::BOOKMARKS_JS, "bookmarks.js");
|
||||
define_gz_cache!(gz_scheduler_js, status::SCHEDULER_JS, "scheduler.js");
|
||||
define_gz_cache!(
|
||||
gz_sat_scheduler_js,
|
||||
status::SAT_SCHEDULER_JS,
|
||||
"sat-scheduler.js"
|
||||
);
|
||||
define_gz_cache!(
|
||||
gz_background_decode_js,
|
||||
status::BACKGROUND_DECODE_JS,
|
||||
"background-decode.js"
|
||||
);
|
||||
define_gz_cache!(gz_vchan_js, status::VCHAN_JS, "vchan.js");
|
||||
define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json");
|
||||
|
||||
// Vendored DSEG14 Classic font
|
||||
// (binary woff2 — served directly, not through gz_cache)
|
||||
|
||||
// Vendored Leaflet 1.9.4
|
||||
define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js");
|
||||
define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML page routes (all serve the SPA index)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/")]
|
||||
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/map")]
|
||||
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/digital-modes")]
|
||||
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/recorder")]
|
||||
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/about")]
|
||||
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_index_html();
|
||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Favicon & logo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/favicon.ico")]
|
||||
pub(crate) async fn favicon() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(FAVICON_BYTES)
|
||||
}
|
||||
|
||||
#[get("/favicon.png")]
|
||||
pub(crate) async fn favicon_png() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(FAVICON_BYTES)
|
||||
}
|
||||
|
||||
#[get("/logo.png")]
|
||||
pub(crate) async fn logo() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(LOGO_BYTES)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/style.css")]
|
||||
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_style_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/themes.css")]
|
||||
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_themes_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JavaScript assets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/app.js")]
|
||||
pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_app_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/map-core.js")]
|
||||
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_map_core_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/screenshot.js")]
|
||||
pub(crate) async fn screenshot_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_screenshot_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/decode-history-worker.js")]
|
||||
pub(crate) async fn decode_history_worker_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_decode_history_worker_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/webgl-renderer.js")]
|
||||
pub(crate) async fn webgl_renderer_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_webgl_renderer_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/leaflet-ais-tracksymbol.js")]
|
||||
pub(crate) async fn leaflet_ais_tracksymbol_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_leaflet_ais_tracksymbol_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/aprs.js")]
|
||||
pub(crate) async fn aprs_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_aprs_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/hf-aprs.js")]
|
||||
pub(crate) async fn hf_aprs_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_hf_aprs_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/ais.js")]
|
||||
pub(crate) async fn ais_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ais_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/vdes.js")]
|
||||
pub(crate) async fn vdes_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_vdes_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/ft8.js")]
|
||||
pub(crate) async fn ft8_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ft8_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/ft4.js")]
|
||||
pub(crate) async fn ft4_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ft4_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/ft2.js")]
|
||||
pub(crate) async fn ft2_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_ft2_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/wspr.js")]
|
||||
pub(crate) async fn wspr_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_wspr_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/cw.js")]
|
||||
pub(crate) async fn cw_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_cw_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/sat.js")]
|
||||
pub(crate) async fn sat_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_sat_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/wefax.js")]
|
||||
pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_wefax_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/images/{filename}")]
|
||||
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
||||
let filename = path.into_inner();
|
||||
// Reject path traversal attempts.
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return HttpResponse::BadRequest().body("invalid filename");
|
||||
}
|
||||
if !filename.ends_with(".png") {
|
||||
return HttpResponse::BadRequest().body("only .png files are accessible");
|
||||
}
|
||||
let dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("wefax");
|
||||
let file_path = dir.join(&filename);
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(data) => HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=86400"))
|
||||
.body(data),
|
||||
Err(_) => HttpResponse::NotFound().body("image not found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/bookmarks.js")]
|
||||
pub(crate) async fn bookmarks_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_bookmarks_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/scheduler.js")]
|
||||
pub(crate) async fn scheduler_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_scheduler_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/sat-scheduler.js")]
|
||||
pub(crate) async fn sat_scheduler_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_sat_scheduler_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/background-decode.js")]
|
||||
pub(crate) async fn background_decode_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_background_decode_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/vchan.js")]
|
||||
pub(crate) async fn vchan_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_vchan_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/bandplan.json")]
|
||||
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_bandplan_json();
|
||||
static_asset_response(&req, "application/json; charset=utf-8", c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendored DSEG14 Classic font
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/vendor/dseg14-classic-latin-400-normal.woff2")]
|
||||
pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "font/woff2"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(status::DSEG14_CLASSIC_WOFF2)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendored Leaflet 1.9.4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[get("/vendor/leaflet.js")]
|
||||
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_leaflet_js();
|
||||
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/vendor/leaflet.css")]
|
||||
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
|
||||
let c = gz_leaflet_css();
|
||||
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||
}
|
||||
|
||||
#[get("/vendor/marker-icon.png")]
|
||||
pub(crate) async fn leaflet_marker_icon() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(status::LEAFLET_MARKER_ICON)
|
||||
}
|
||||
|
||||
#[get("/vendor/marker-icon-2x.png")]
|
||||
pub(crate) async fn leaflet_marker_icon_2x() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(status::LEAFLET_MARKER_ICON_2X)
|
||||
}
|
||||
|
||||
#[get("/vendor/marker-shadow.png")]
|
||||
pub(crate) async fn leaflet_marker_shadow() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(status::LEAFLET_MARKER_SHADOW)
|
||||
}
|
||||
|
||||
#[get("/vendor/layers.png")]
|
||||
pub(crate) async fn leaflet_layers() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(status::LEAFLET_LAYERS)
|
||||
}
|
||||
|
||||
#[get("/vendor/layers-2x.png")]
|
||||
pub(crate) async fn leaflet_layers_2x() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
|
||||
.body(status::LEAFLET_LAYERS_2X)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Bookmark CRUD endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::Error;
|
||||
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
|
||||
|
||||
use super::{no_cache_response, request_accepts_html, require_control};
|
||||
use crate::server::status;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkQuery {
|
||||
pub category: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkScopeQuery {
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BookmarkInput {
|
||||
pub name: String,
|
||||
pub freq_hz: u64,
|
||||
pub mode: String,
|
||||
pub bandwidth_hz: Option<u64>,
|
||||
pub locator: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub decoders: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// A bookmark with its owning scope tag for the list response.
|
||||
#[derive(serde::Serialize)]
|
||||
struct BookmarkWithScope {
|
||||
#[serde(flatten)]
|
||||
bm: crate::server::bookmarks::Bookmark,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchDeleteRequest {
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchMoveRequest {
|
||||
ids: Vec<String>,
|
||||
to: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Resolve which `BookmarkStore` to use based on the `scope` parameter.
|
||||
fn resolve_bookmark_store(
|
||||
scope: Option<&str>,
|
||||
store_map: &crate::server::bookmarks::BookmarkStoreMap,
|
||||
) -> std::sync::Arc<crate::server::bookmarks::BookmarkStore> {
|
||||
match scope.filter(|s| !s.is_empty() && *s != "general") {
|
||||
Some(remote) => store_map.store_for(remote),
|
||||
None => store_map.general().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_bookmark_id() -> String {
|
||||
hex::encode(rand::random::<[u8; 16]>())
|
||||
}
|
||||
|
||||
fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
|
||||
locator.and_then(|value| {
|
||||
let trimmed = value.trim().to_uppercase();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[get("/bookmarks")]
|
||||
pub async fn list_bookmarks(
|
||||
req: HttpRequest,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkQuery>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
if request_accepts_html(&req) {
|
||||
return Ok(no_cache_response(
|
||||
"text/html; charset=utf-8",
|
||||
status::index_html(),
|
||||
));
|
||||
}
|
||||
let scope = query
|
||||
.scope
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "general");
|
||||
let mut list: Vec<BookmarkWithScope> = match scope {
|
||||
Some(remote) => {
|
||||
let mut map: std::collections::HashMap<String, BookmarkWithScope> = store_map
|
||||
.general()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|bm| {
|
||||
let id = bm.id.clone();
|
||||
(
|
||||
id,
|
||||
BookmarkWithScope {
|
||||
bm,
|
||||
scope: "general".into(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for bm in store_map.store_for(remote).list() {
|
||||
let id = bm.id.clone();
|
||||
map.insert(
|
||||
id,
|
||||
BookmarkWithScope {
|
||||
bm,
|
||||
scope: remote.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
map.into_values().collect()
|
||||
}
|
||||
None => store_map
|
||||
.general()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|bm| BookmarkWithScope {
|
||||
bm,
|
||||
scope: "general".into(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
if let Some(ref cat) = query.category {
|
||||
if !cat.is_empty() {
|
||||
let cat_lower = cat.to_lowercase();
|
||||
list.retain(|item| item.bm.category.to_lowercase() == cat_lower);
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|item| item.bm.freq_hz);
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
||||
|
||||
#[post("/bookmarks")]
|
||||
pub async fn create_bookmark(
|
||||
req: HttpRequest,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
if store.freq_taken(body.freq_hz, None) {
|
||||
return Err(actix_web::error::ErrorConflict(
|
||||
"a bookmark for that frequency already exists",
|
||||
));
|
||||
}
|
||||
let bm = crate::server::bookmarks::Bookmark {
|
||||
id: gen_bookmark_id(),
|
||||
name: body.name.clone(),
|
||||
freq_hz: body.freq_hz,
|
||||
mode: body.mode.clone(),
|
||||
bandwidth_hz: body.bandwidth_hz,
|
||||
locator: normalize_bookmark_locator(body.locator.clone()),
|
||||
comment: body.comment.clone().unwrap_or_default(),
|
||||
category: body.category.clone().unwrap_or_default(),
|
||||
decoders: body.decoders.clone().unwrap_or_default(),
|
||||
};
|
||||
if store.insert(&bm) {
|
||||
Ok(HttpResponse::Created().json(bm))
|
||||
} else {
|
||||
Err(actix_web::error::ErrorInternalServerError(
|
||||
"failed to save bookmark",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/bookmarks/{id}")]
|
||||
pub async fn update_bookmark(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let id = path.into_inner();
|
||||
if store.freq_taken(body.freq_hz, Some(&id)) {
|
||||
return Err(actix_web::error::ErrorConflict(
|
||||
"a bookmark for that frequency already exists",
|
||||
));
|
||||
}
|
||||
let bm = crate::server::bookmarks::Bookmark {
|
||||
id: id.clone(),
|
||||
name: body.name.clone(),
|
||||
freq_hz: body.freq_hz,
|
||||
mode: body.mode.clone(),
|
||||
bandwidth_hz: body.bandwidth_hz,
|
||||
locator: normalize_bookmark_locator(body.locator.clone()),
|
||||
comment: body.comment.clone().unwrap_or_default(),
|
||||
category: body.category.clone().unwrap_or_default(),
|
||||
decoders: body.decoders.clone().unwrap_or_default(),
|
||||
};
|
||||
if store.upsert(&id, &bm) {
|
||||
Ok(HttpResponse::Ok().json(bm))
|
||||
} else {
|
||||
Err(actix_web::error::ErrorNotFound("bookmark not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/bookmarks/{id}")]
|
||||
pub async fn delete_bookmark(
|
||||
req: HttpRequest,
|
||||
path: web::Path<String>,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let id = path.into_inner();
|
||||
if store.remove(&id) {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true })))
|
||||
} else {
|
||||
Err(actix_web::error::ErrorNotFound("bookmark not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/bookmarks/batch_delete")]
|
||||
pub async fn batch_delete_bookmarks(
|
||||
req: HttpRequest,
|
||||
body: web::Json<BatchDeleteRequest>,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let mut deleted = 0usize;
|
||||
for id in &body.ids {
|
||||
if store.remove(id) {
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
|
||||
}
|
||||
|
||||
#[post("/bookmarks/batch_move")]
|
||||
pub async fn batch_move_bookmarks(
|
||||
req: HttpRequest,
|
||||
body: web::Json<BatchMoveRequest>,
|
||||
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_control(&req, &auth_state)?;
|
||||
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
|
||||
let mut moved = 0usize;
|
||||
for id in &body.ids {
|
||||
if let Some(bm) = from_store.get(id) {
|
||||
if to_store.insert(&bm) && from_store.remove(id) {
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "moved": moved })))
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Decoder toggle/clear endpoints and decode history.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::Error;
|
||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{select, StreamExt};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::time::{self, Duration};
|
||||
use tokio_stream::wrappers::IntervalStream;
|
||||
|
||||
use trx_core::{RigCommand, RigRequest, RigState};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
use super::{gzip_bytes, send_command, RemoteQuery};
|
||||
|
||||
/// Resolve the rig state for a specific remote, falling back to the global
|
||||
/// default when no `remote` is given or the rig is unknown.
|
||||
fn resolve_rig_state(
|
||||
remote: Option<&str>,
|
||||
context: &FrontendRuntimeContext,
|
||||
fallback: &watch::Receiver<RigState>,
|
||||
) -> RigState {
|
||||
remote
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|rid| context.rig_state_rx(rid))
|
||||
.unwrap_or_else(|| fallback.clone())
|
||||
.borrow()
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoder registry
|
||||
// ============================================================================
|
||||
|
||||
#[get("/decoders")]
|
||||
pub async fn decoder_registry() -> impl Responder {
|
||||
HttpResponse::Ok().json(trx_protocol::DECODER_REGISTRY)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decode history types and helpers
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DecodeHistoryPayload {
|
||||
ais: Vec<trx_core::decode::AisMessage>,
|
||||
vdes: Vec<trx_core::decode::VdesMessage>,
|
||||
aprs: Vec<trx_core::decode::AprsPacket>,
|
||||
hf_aprs: Vec<trx_core::decode::AprsPacket>,
|
||||
cw: Vec<trx_core::decode::CwEvent>,
|
||||
ft8: Vec<trx_core::decode::Ft8Message>,
|
||||
ft4: Vec<trx_core::decode::Ft8Message>,
|
||||
ft2: Vec<trx_core::decode::Ft8Message>,
|
||||
wspr: Vec<trx_core::decode::WsprMessage>,
|
||||
wefax: Vec<trx_core::decode::WefaxMessage>,
|
||||
}
|
||||
|
||||
impl DecodeHistoryPayload {
|
||||
fn total_messages(&self) -> usize {
|
||||
self.ais.len()
|
||||
+ self.vdes.len()
|
||||
+ self.aprs.len()
|
||||
+ self.hf_aprs.len()
|
||||
+ self.cw.len()
|
||||
+ self.ft8.len()
|
||||
+ self.ft4.len()
|
||||
+ self.ft2.len()
|
||||
+ self.wspr.len()
|
||||
+ self.wefax.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the grouped decode history payload from all per-decoder ring-buffers.
|
||||
fn collect_decode_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> DecodeHistoryPayload {
|
||||
DecodeHistoryPayload {
|
||||
ais: crate::server::audio::snapshot_ais_history(context, rig_filter),
|
||||
vdes: crate::server::audio::snapshot_vdes_history(context, rig_filter),
|
||||
aprs: crate::server::audio::snapshot_aprs_history(context, rig_filter),
|
||||
hf_aprs: crate::server::audio::snapshot_hf_aprs_history(context, rig_filter),
|
||||
cw: crate::server::audio::snapshot_cw_history(context, rig_filter),
|
||||
ft8: crate::server::audio::snapshot_ft8_history(context, rig_filter),
|
||||
ft4: crate::server::audio::snapshot_ft4_history(context, rig_filter),
|
||||
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
||||
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
||||
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cbor_length(out: &mut Vec<u8>, major: u8, value: u64) {
|
||||
debug_assert!(major <= 7);
|
||||
match value {
|
||||
0..=23 => out.push((major << 5) | (value as u8)),
|
||||
24..=0xff => {
|
||||
out.push((major << 5) | 24);
|
||||
out.push(value as u8);
|
||||
}
|
||||
0x100..=0xffff => {
|
||||
out.push((major << 5) | 25);
|
||||
out.extend_from_slice(&(value as u16).to_be_bytes());
|
||||
}
|
||||
0x1_0000..=0xffff_ffff => {
|
||||
out.push((major << 5) | 26);
|
||||
out.extend_from_slice(&(value as u32).to_be_bytes());
|
||||
}
|
||||
_ => {
|
||||
out.push((major << 5) | 27);
|
||||
out.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cbor_json_value(out: &mut Vec<u8>, value: &serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Null => out.push(0xf6),
|
||||
serde_json::Value::Bool(false) => out.push(0xf4),
|
||||
serde_json::Value::Bool(true) => out.push(0xf5),
|
||||
serde_json::Value::Number(number) => {
|
||||
if let Some(value) = number.as_u64() {
|
||||
encode_cbor_length(out, 0, value);
|
||||
} else if let Some(value) = number.as_i64() {
|
||||
if value >= 0 {
|
||||
encode_cbor_length(out, 0, value as u64);
|
||||
} else {
|
||||
encode_cbor_length(out, 1, value.unsigned_abs() - 1);
|
||||
}
|
||||
} else if let Some(value) = number.as_f64() {
|
||||
out.push(0xfb);
|
||||
out.extend_from_slice(&value.to_be_bytes());
|
||||
} else {
|
||||
out.push(0xf6);
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(text) => {
|
||||
encode_cbor_length(out, 3, text.len() as u64);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
encode_cbor_length(out, 4, items.len() as u64);
|
||||
for item in items {
|
||||
encode_cbor_json_value(out, item);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
encode_cbor_length(out, 5, map.len() as u64);
|
||||
for (key, item) in map {
|
||||
encode_cbor_length(out, 3, key.len() as u64);
|
||||
out.extend_from_slice(key.as_bytes());
|
||||
encode_cbor_json_value(out, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_decode_history_cbor(
|
||||
history: &DecodeHistoryPayload,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let value = serde_json::to_value(history)?;
|
||||
let mut out = Vec::with_capacity(history.total_messages().saturating_mul(96));
|
||||
encode_cbor_json_value(&mut out, &value);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decode history endpoint
|
||||
// ============================================================================
|
||||
|
||||
/// `GET /decode/history` — returns the full decode history as gzipped CBOR.
|
||||
#[get("/decode/history")]
|
||||
pub async fn decode_history(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
query: web::Query<RemoteQuery>,
|
||||
) -> impl Responder {
|
||||
if context.audio.decode_rx.is_none() {
|
||||
return HttpResponse::NotFound().body("decode not enabled");
|
||||
}
|
||||
let rig_filter = query.remote.as_deref().filter(|s| !s.is_empty());
|
||||
let history = collect_decode_history(context.get_ref(), rig_filter);
|
||||
let cbor = match encode_decode_history_cbor(&history) {
|
||||
Ok(cbor) => cbor,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to encode decode history as CBOR: {err}");
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
let payload = match gzip_bytes(&cbor) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to gzip decode history payload: {err}");
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "application/cbor"))
|
||||
.insert_header((header::CONTENT_ENCODING, "gzip"))
|
||||
.body(payload)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decode SSE stream
|
||||
// ============================================================================
|
||||
|
||||
#[get("/decode")]
|
||||
pub async fn decode_events(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let Some(decode_rx) = crate::server::audio::subscribe_decode(context.get_ref()) else {
|
||||
tracing::warn!("/decode requested but decode channel not set (audio disabled?)");
|
||||
return Ok(HttpResponse::NotFound().body("decode not enabled"));
|
||||
};
|
||||
tracing::info!("/decode SSE client connected");
|
||||
|
||||
let decode_stream = futures_util::stream::unfold(decode_rx, |mut rx| async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
return Some((
|
||||
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))),
|
||||
rx,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
|
||||
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
|
||||
|
||||
let stream = select(pings, decode_stream);
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoder toggle endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[post("/toggle_aprs_decode")]
|
||||
pub async fn toggle_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetAprsDecodeEnabled(!rig_state.decoders.aprs_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_hf_aprs_decode")]
|
||||
pub async fn toggle_hf_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetHfAprsDecodeEnabled(!rig_state.decoders.hf_aprs_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_cw_decode")]
|
||||
pub async fn toggle_cw_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetCwDecodeEnabled(!rig_state.decoders.cw_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CwAutoQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_cw_auto")]
|
||||
pub async fn set_cw_auto(
|
||||
query: web::Query<CwAutoQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetCwAuto(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CwWpmQuery {
|
||||
pub wpm: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_cw_wpm")]
|
||||
pub async fn set_cw_wpm(
|
||||
query: web::Query<CwWpmQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetCwWpm(q.wpm), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CwToneQuery {
|
||||
pub tone_hz: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_cw_tone")]
|
||||
pub async fn set_cw_tone(
|
||||
query: web::Query<CwToneQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetCwToneHz(q.tone_hz), q.remote).await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft8_decode")]
|
||||
pub async fn toggle_ft8_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetFt8DecodeEnabled(!rig_state.decoders.ft8_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft4_decode")]
|
||||
pub async fn toggle_ft4_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetFt4DecodeEnabled(!rig_state.decoders.ft4_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_ft2_decode")]
|
||||
pub async fn toggle_ft2_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetFt2DecodeEnabled(!rig_state.decoders.ft2_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_wspr_decode")]
|
||||
pub async fn toggle_wspr_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetWsprDecodeEnabled(!rig_state.decoders.wspr_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_lrpt_decode")]
|
||||
pub async fn toggle_lrpt_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetLrptDecodeEnabled(!rig_state.decoders.lrpt_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/toggle_wefax_decode")]
|
||||
pub async fn toggle_wefax_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetWefaxDecodeEnabled(!rig_state.decoders.wefax_decode_enabled),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decoder clear endpoints
|
||||
// ============================================================================
|
||||
|
||||
#[post("/clear_wefax_decode")]
|
||||
pub async fn clear_wefax_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetWefaxDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_lrpt_decode")]
|
||||
pub async fn clear_lrpt_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetLrptDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ft8_decode")]
|
||||
pub async fn clear_ft8_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ft8_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetFt8Decoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ft4_decode")]
|
||||
pub async fn clear_ft4_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ft4_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetFt4Decoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ft2_decode")]
|
||||
pub async fn clear_ft2_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ft2_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetFt2Decoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_wspr_decode")]
|
||||
pub async fn clear_wspr_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_wspr_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetWsprDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_aprs_decode")]
|
||||
pub async fn clear_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_aprs_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetAprsDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_hf_aprs_decode")]
|
||||
pub async fn clear_hf_aprs_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_hf_aprs_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetHfAprsDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[post("/clear_ais_decode")]
|
||||
pub async fn clear_ais_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_ais_history(context.get_ref());
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[post("/clear_vdes_decode")]
|
||||
pub async fn clear_vdes_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_vdes_history(context.get_ref());
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[post("/clear_cw_decode")]
|
||||
pub async fn clear_cw_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
crate::server::audio::clear_cw_history(context.get_ref());
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::ResetCwDecoder,
|
||||
query.into_inner().remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! HTTP API endpoints for audio recording.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::{delete, get, post, web, Error, HttpResponse};
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
use trx_core::{RigCommand, RigState};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
use super::send_command;
|
||||
use crate::server::recorder::RecorderManager;
|
||||
|
||||
// ============================================================================
|
||||
// Query types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RecorderStartQuery {
|
||||
pub remote: Option<String>,
|
||||
pub vchan_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RecorderStopQuery {
|
||||
pub remote: Option<String>,
|
||||
pub vchan_id: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/// Start recording audio for the active rig (or a specific vchan).
|
||||
#[post("/api/recorder/start")]
|
||||
pub async fn recorder_start(
|
||||
query: web::Query<RecorderStartQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<trx_core::RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let rig_id = resolve_rig_id(&context, query.remote.as_deref());
|
||||
let vchan_id = query.vchan_id.as_deref();
|
||||
|
||||
// Resolve the audio broadcast sender for this rig/vchan.
|
||||
let (audio_tx, sample_rate, channels, frame_duration_ms) =
|
||||
resolve_audio_source(&context, &rig_id, vchan_id)?;
|
||||
|
||||
let current_state = state.get_ref().borrow().clone();
|
||||
let freq_hz = Some(current_state.status.freq.hz);
|
||||
let mode = Some(trx_protocol::mode_to_string(¤t_state.status.mode).into_owned());
|
||||
|
||||
let params = crate::server::recorder::AudioParams {
|
||||
sample_rate,
|
||||
channels,
|
||||
frame_duration_ms,
|
||||
};
|
||||
|
||||
match recorder_mgr.start(
|
||||
&rig_id,
|
||||
vchan_id,
|
||||
audio_tx,
|
||||
params,
|
||||
freq_hz,
|
||||
mode.as_deref(),
|
||||
) {
|
||||
Ok(info) => {
|
||||
// Sync recorder_enabled state to the rig.
|
||||
let _ = send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetRecorderEnabled(true),
|
||||
query.remote.clone(),
|
||||
)
|
||||
.await;
|
||||
Ok(HttpResponse::Ok().json(info))
|
||||
}
|
||||
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop recording.
|
||||
#[post("/api/recorder/stop")]
|
||||
pub async fn recorder_stop(
|
||||
query: web::Query<RecorderStopQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||
rig_tx: web::Data<mpsc::Sender<trx_core::RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let rig_id = resolve_rig_id(&context, query.remote.as_deref());
|
||||
let vchan_id = query.vchan_id.as_deref();
|
||||
|
||||
match recorder_mgr.stop(&rig_id, vchan_id).await {
|
||||
Ok(result) => {
|
||||
// Check if any recordings remain active for this rig.
|
||||
let still_recording = recorder_mgr
|
||||
.list_active()
|
||||
.iter()
|
||||
.any(|r| r.rig_id == rig_id);
|
||||
if !still_recording {
|
||||
let _ = send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetRecorderEnabled(false),
|
||||
query.remote.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(result))
|
||||
}
|
||||
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the status of active recordings.
|
||||
#[get("/api/recorder/status")]
|
||||
pub async fn recorder_status(
|
||||
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let active = recorder_mgr.list_active();
|
||||
Ok(HttpResponse::Ok().json(active))
|
||||
}
|
||||
|
||||
/// List recorded files in the output directory.
|
||||
#[get("/api/recorder/files")]
|
||||
pub async fn recorder_files(
|
||||
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let files = recorder_mgr.list_files();
|
||||
Ok(HttpResponse::Ok().json(files))
|
||||
}
|
||||
|
||||
/// Download a recorded file.
|
||||
#[get("/api/recorder/download/{filename}")]
|
||||
pub async fn recorder_download(
|
||||
path: web::Path<String>,
|
||||
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let filename = path.into_inner();
|
||||
let file_path = recorder_mgr
|
||||
.file_path(&filename)
|
||||
.map_err(actix_web::error::ErrorNotFound)?;
|
||||
|
||||
let data = tokio::fs::read(&file_path)
|
||||
.await
|
||||
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("read error: {e}")))?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "audio/ogg"))
|
||||
.insert_header((
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
))
|
||||
.body(Bytes::from(data)))
|
||||
}
|
||||
|
||||
/// Delete a recorded file.
|
||||
#[delete("/api/recorder/files/{filename}")]
|
||||
pub async fn recorder_delete(
|
||||
path: web::Path<String>,
|
||||
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let filename = path.into_inner();
|
||||
match recorder_mgr.delete_file(&filename) {
|
||||
Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": filename }))),
|
||||
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn resolve_rig_id(context: &FrontendRuntimeContext, remote: Option<&str>) -> String {
|
||||
if let Some(r) = remote {
|
||||
return r.to_string();
|
||||
}
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
.unwrap_or_else(|| "default".to_string())
|
||||
}
|
||||
|
||||
fn resolve_audio_source(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_id: &str,
|
||||
vchan_id: Option<&str>,
|
||||
) -> Result<(tokio::sync::broadcast::Sender<bytes::Bytes>, u32, u8, u16), Error> {
|
||||
if let Some(vchan_uuid_str) = vchan_id {
|
||||
// Virtual channel audio.
|
||||
let uuid: uuid::Uuid = vchan_uuid_str
|
||||
.parse()
|
||||
.map_err(|_| actix_web::error::ErrorBadRequest("invalid vchan_id UUID"))?;
|
||||
let audio = context
|
||||
.vchan
|
||||
.audio
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tx = audio
|
||||
.get(&uuid)
|
||||
.cloned()
|
||||
.ok_or_else(|| actix_web::error::ErrorNotFound("vchan audio not found"))?;
|
||||
// Virtual channels use the same stream info as the main rig.
|
||||
let (sr, ch, fd) = stream_info_for_rig(context, rig_id);
|
||||
Ok((tx, sr, ch, fd))
|
||||
} else {
|
||||
// Main rig audio — try per-rig first, then default.
|
||||
let tx = context
|
||||
.rig_audio
|
||||
.rx
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|map| map.get(rig_id).cloned())
|
||||
.or_else(|| context.audio.rx.clone())
|
||||
.ok_or_else(|| actix_web::error::ErrorNotFound("no audio source for rig"))?;
|
||||
|
||||
let (sr, ch, fd) = stream_info_for_rig(context, rig_id);
|
||||
Ok((tx, sr, ch, fd))
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_info_for_rig(context: &FrontendRuntimeContext, rig_id: &str) -> (u32, u8, u16) {
|
||||
// Try per-rig stream info first.
|
||||
if let Some(rx) = context.rig_audio_info_rx(rig_id) {
|
||||
if let Some(info) = rx.borrow().as_ref() {
|
||||
return (info.sample_rate, info.channels, info.frame_duration_ms);
|
||||
}
|
||||
}
|
||||
// Fall back to the default audio info.
|
||||
if let Some(ref info_rx) = context.audio.info {
|
||||
if let Some(info) = info_rx.borrow().as_ref() {
|
||||
return (info.sample_rate, info.channels, info.frame_duration_ms);
|
||||
}
|
||||
}
|
||||
// Absolute fallback.
|
||||
(48000, 2, 20)
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Rig control endpoints: status, frequency, mode, PTT, SDR settings, etc.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
||||
use actix_web::{http::header, Error};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::state::WfmDenoiseLevel;
|
||||
use trx_core::{RigCommand, RigRequest, RigState};
|
||||
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||
use trx_protocol::parse_mode;
|
||||
|
||||
use crate::server::vchan::ClientChannelManager;
|
||||
|
||||
use super::{
|
||||
active_rig_id_from_context, frontend_meta_from_context, send_command, wait_for_view,
|
||||
RemoteQuery, SessionRigManager, SnapshotWithMeta, StatusQuery,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Status
|
||||
// ============================================================================
|
||||
|
||||
#[get("/status")]
|
||||
pub async fn status_api(
|
||||
query: web::Query<StatusQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
clients: web::Data<Arc<AtomicUsize>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
let rx = query
|
||||
.remote
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|rid| context.rig_state_rx(rid))
|
||||
.unwrap_or_else(|| state.get_ref().clone());
|
||||
let snapshot = wait_for_view(rx).await?;
|
||||
let combined = SnapshotWithMeta {
|
||||
snapshot: &snapshot,
|
||||
meta: frontend_meta_from_context(
|
||||
clients.load(Ordering::Relaxed),
|
||||
context.get_ref().as_ref(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let json =
|
||||
serde_json::to_string(&combined).map_err(actix_web::error::ErrorInternalServerError)?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "application/json"))
|
||||
.body(json))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Power / VFO / Lock
|
||||
// ============================================================================
|
||||
|
||||
#[post("/toggle_power")]
|
||||
pub async fn toggle_power(
|
||||
query: web::Query<RemoteQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let desired_on = !matches!(state.get_ref().borrow().control.enabled, Some(true));
|
||||
let cmd = if desired_on {
|
||||
RigCommand::PowerOn
|
||||
} else {
|
||||
RigCommand::PowerOff
|
||||
};
|
||||
send_command(&rig_tx, cmd, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
#[post("/toggle_vfo")]
|
||||
pub async fn toggle_vfo(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::ToggleVfo, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
#[post("/lock")]
|
||||
pub async fn lock_panel(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::Lock, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
#[post("/unlock")]
|
||||
pub async fn unlock_panel(
|
||||
query: web::Query<RemoteQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
send_command(&rig_tx, RigCommand::Unlock, query.into_inner().remote).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frequency / Mode / PTT
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FreqQuery {
|
||||
pub hz: u64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_freq")]
|
||||
pub async fn set_freq(
|
||||
query: web::Query<FreqQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: q.hz }), q.remote).await
|
||||
}
|
||||
|
||||
#[post("/set_center_freq")]
|
||||
pub async fn set_center_freq(
|
||||
query: web::Query<FreqQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetCenterFreq(Freq { hz: q.hz }),
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ModeQuery {
|
||||
pub mode: String,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_mode")]
|
||||
pub async fn set_mode(
|
||||
query: web::Query<ModeQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let mode = parse_mode(&q.mode);
|
||||
send_command(&rig_tx, RigCommand::SetMode(mode), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PttQuery {
|
||||
pub ptt: String,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_ptt")]
|
||||
pub async fn set_ptt(
|
||||
query: web::Query<PttQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
let ptt = match q.ptt.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "on" => Ok(true),
|
||||
"0" | "false" | "off" => Ok(false),
|
||||
other => Err(actix_web::error::ErrorBadRequest(format!(
|
||||
"invalid ptt parameter: {other}"
|
||||
))),
|
||||
}?;
|
||||
send_command(&rig_tx, RigCommand::SetPtt(ptt), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TxLimitQuery {
|
||||
pub limit: u8,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_tx_limit")]
|
||||
pub async fn set_tx_limit(
|
||||
query: web::Query<TxLimitQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetTxLimit(q.limit), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BandwidthQuery {
|
||||
pub hz: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_bandwidth")]
|
||||
pub async fn set_bandwidth(
|
||||
query: web::Query<BandwidthQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetBandwidth(q.hz), q.remote).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SDR settings
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrGainQuery {
|
||||
pub db: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_gain")]
|
||||
pub async fn set_sdr_gain(
|
||||
query: web::Query<SdrGainQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSdrGain(q.db), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrLnaGainQuery {
|
||||
pub db: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_lna_gain")]
|
||||
pub async fn set_sdr_lna_gain(
|
||||
query: web::Query<SdrLnaGainQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSdrLnaGain(q.db), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrAgcQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_agc")]
|
||||
pub async fn set_sdr_agc(
|
||||
query: web::Query<SdrAgcQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSdrAgc(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrSquelchQuery {
|
||||
pub enabled: bool,
|
||||
pub threshold_db: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_squelch")]
|
||||
pub async fn set_sdr_squelch(
|
||||
query: web::Query<SdrSquelchQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetSdrSquelch {
|
||||
enabled: q.enabled,
|
||||
threshold_db: q.threshold_db,
|
||||
},
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SdrNoiseBlankerQuery {
|
||||
pub enabled: bool,
|
||||
pub threshold: f64,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sdr_noise_blanker")]
|
||||
pub async fn set_sdr_noise_blanker(
|
||||
query: web::Query<SdrNoiseBlankerQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(
|
||||
&rig_tx,
|
||||
RigCommand::SetSdrNoiseBlanker {
|
||||
enabled: q.enabled,
|
||||
threshold: q.threshold,
|
||||
},
|
||||
q.remote,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WFM / SAM settings
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WfmDeemphasisQuery {
|
||||
pub us: u32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_wfm_deemphasis")]
|
||||
pub async fn set_wfm_deemphasis(
|
||||
query: web::Query<WfmDeemphasisQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetWfmDeemphasis(q.us), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WfmStereoQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_wfm_stereo")]
|
||||
pub async fn set_wfm_stereo(
|
||||
query: web::Query<WfmStereoQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetWfmStereo(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WfmDenoiseQuery {
|
||||
pub level: WfmDenoiseLevel,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_wfm_denoise")]
|
||||
pub async fn set_wfm_denoise(
|
||||
query: web::Query<WfmDenoiseQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetWfmDenoise(q.level), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SamStereoWidthQuery {
|
||||
pub width: f32,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sam_stereo_width")]
|
||||
pub async fn set_sam_stereo_width(
|
||||
query: web::Query<SamStereoWidthQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSamStereoWidth(q.width), q.remote).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SamCarrierSyncQuery {
|
||||
pub enabled: bool,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/set_sam_carrier_sync")]
|
||||
pub async fn set_sam_carrier_sync(
|
||||
query: web::Query<SamCarrierSyncQuery>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let q = query.into_inner();
|
||||
send_command(&rig_tx, RigCommand::SetSamCarrierSync(q.enabled), q.remote).await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rig list / selection
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListItem {
|
||||
remote: String,
|
||||
display_name: Option<String>,
|
||||
manufacturer: String,
|
||||
model: String,
|
||||
initialized: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
latitude: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
longitude: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RigListResponse {
|
||||
active_remote: Option<String>,
|
||||
rigs: Vec<RigListItem>,
|
||||
}
|
||||
|
||||
fn build_rig_list_payload(context: &FrontendRuntimeContext) -> RigListResponse {
|
||||
let active_remote = active_rig_id_from_context(context);
|
||||
let rigs = context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().map(map_rig_entry).collect())
|
||||
.unwrap_or_default();
|
||||
RigListResponse {
|
||||
active_remote,
|
||||
rigs,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
|
||||
RigListItem {
|
||||
remote: entry.rig_id.clone(),
|
||||
display_name: entry.display_name.clone(),
|
||||
manufacturer: entry.state.info.manufacturer.clone(),
|
||||
model: entry.state.info.model.clone(),
|
||||
initialized: entry.state.initialized,
|
||||
latitude: entry.state.server_latitude,
|
||||
longitude: entry.state.server_longitude,
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/rigs")]
|
||||
pub async fn list_rigs(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SelectRigQuery {
|
||||
pub remote: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/select_rig")]
|
||||
pub async fn select_rig(
|
||||
query: web::Query<SelectRigQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
session_rig_mgr: web::Data<Arc<SessionRigManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let remote = query.remote.trim();
|
||||
if remote.is_empty() {
|
||||
return Err(actix_web::error::ErrorBadRequest(
|
||||
"remote must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let known = context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|entries| entries.iter().any(|entry| entry.rig_id == remote))
|
||||
.unwrap_or(false);
|
||||
if !known {
|
||||
return Err(actix_web::error::ErrorBadRequest(format!(
|
||||
"unknown remote: {remote}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Only update per-session rig selection — never mutate the global
|
||||
// active rig so that other tabs/sessions are unaffected.
|
||||
if let Some(ref sid) = query.session_id {
|
||||
if let Ok(uuid) = Uuid::parse_str(sid) {
|
||||
session_rig_mgr.set_rig(uuid, remote.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast the channel list for the newly selected rig so all SSE
|
||||
// clients receive the correct virtual channels immediately.
|
||||
let chans = vchan_mgr.channels(remote);
|
||||
if let Ok(json) = serde_json::to_string(&chans) {
|
||||
let _ = vchan_mgr.change_tx.send(format!("{remote}:{json}"));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(build_rig_list_payload(context.get_ref().as_ref())))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Satellite passes
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SatPassesResponse {
|
||||
passes: Vec<trx_core::geo::PassPrediction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
/// Number of satellites evaluated for predictions.
|
||||
satellite_count: usize,
|
||||
/// Source of the TLE data used: "celestrak" or "unavailable".
|
||||
tle_source: trx_core::geo::TleSource,
|
||||
}
|
||||
|
||||
/// Return predicted passes for all known satellites over the next 24 h.
|
||||
#[get("/sat_passes")]
|
||||
pub async fn sat_passes(context: web::Data<Arc<FrontendRuntimeContext>>) -> impl Responder {
|
||||
let cached = context
|
||||
.routing
|
||||
.sat_passes
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|g| g.clone());
|
||||
match cached {
|
||||
Some(result) => {
|
||||
let error = match result.tle_source {
|
||||
trx_core::geo::TleSource::Unavailable => {
|
||||
Some("TLE data not yet available — waiting for CelesTrak fetch".to_string())
|
||||
}
|
||||
trx_core::geo::TleSource::Celestrak => None,
|
||||
};
|
||||
web::Json(SatPassesResponse {
|
||||
passes: result.passes,
|
||||
error,
|
||||
satellite_count: result.satellite_count,
|
||||
tle_source: result.tle_source,
|
||||
})
|
||||
}
|
||||
None => web::Json(SatPassesResponse {
|
||||
passes: vec![],
|
||||
error: Some("Satellite predictions not yet available from server".to_string()),
|
||||
satellite_count: 0,
|
||||
tle_source: trx_core::geo::TleSource::Unavailable,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! SSE stream endpoints: /events (rig state) and /spectrum.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::Error;
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::{select, StreamExt};
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio::time::{self, Duration};
|
||||
use tokio_stream::wrappers::{IntervalStream, WatchStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::RigState;
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
use trx_protocol::MeterUpdate;
|
||||
|
||||
use crate::server::vchan::ClientChannelManager;
|
||||
|
||||
use super::{
|
||||
base64_encode, frontend_meta_from_context, wait_for_view, RemoteQuery, SessionRigManager,
|
||||
SnapshotWithMeta,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// DropStream utility
|
||||
// ============================================================================
|
||||
|
||||
/// A stream wrapper that calls a callback when dropped.
|
||||
struct DropStream<I> {
|
||||
inner: std::pin::Pin<Box<dyn futures_util::Stream<Item = I> + 'static>>,
|
||||
on_drop: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl<I> DropStream<I> {
|
||||
fn new<S, F>(inner: std::pin::Pin<Box<S>>, on_drop: F) -> Self
|
||||
where
|
||||
S: futures_util::Stream<Item = I> + 'static,
|
||||
F: FnOnce() + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
inner,
|
||||
on_drop: Some(Box::new(on_drop)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> Drop for DropStream<I> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(f) = self.on_drop.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> futures_util::Stream for DropStream<I> {
|
||||
type Item = I;
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
self.inner.as_mut().poll_next(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Spectrum encoding
|
||||
// ============================================================================
|
||||
|
||||
/// Encode spectrum bins as a compact base64 string of i8 values (1 dB/step).
|
||||
fn encode_spectrum_frame(frame: &trx_core::rig::state::SpectrumData) -> String {
|
||||
let clamped: Vec<u8> = frame
|
||||
.bins
|
||||
.iter()
|
||||
.map(|&v| v.round().clamp(-128.0, 127.0) as i8 as u8)
|
||||
.collect();
|
||||
let b64 = base64_encode(&clamped);
|
||||
|
||||
let mut out = String::with_capacity(40 + b64.len());
|
||||
out.push_str(&frame.center_hz.to_string());
|
||||
out.push(',');
|
||||
out.push_str(&frame.sample_rate.to_string());
|
||||
out.push(',');
|
||||
out.push_str(&b64);
|
||||
out
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scheduler vchannel sync helper
|
||||
// ============================================================================
|
||||
|
||||
fn sync_scheduler_vchannels(
|
||||
vchan_mgr: &ClientChannelManager,
|
||||
bookmark_store_map: &crate::server::bookmarks::BookmarkStoreMap,
|
||||
scheduler_status: &crate::server::scheduler::SchedulerStatusMap,
|
||||
scheduler_control: &crate::server::scheduler::SchedulerControlManager,
|
||||
rig_id: &str,
|
||||
) {
|
||||
if !scheduler_control.scheduler_allowed() {
|
||||
vchan_mgr.sync_scheduler_channels(rig_id, &[]);
|
||||
return;
|
||||
}
|
||||
|
||||
let desired = {
|
||||
let map = scheduler_status.read().unwrap_or_else(|e| e.into_inner());
|
||||
map.get(rig_id)
|
||||
.filter(|status| status.active)
|
||||
.map(|status| {
|
||||
status
|
||||
.last_bookmark_ids
|
||||
.iter()
|
||||
.filter_map(|bookmark_id| {
|
||||
bookmark_store_map
|
||||
.get_for_rig(rig_id, bookmark_id)
|
||||
.map(|bookmark| {
|
||||
(
|
||||
bookmark_id.clone(),
|
||||
bookmark.freq_hz,
|
||||
bookmark.mode.clone(),
|
||||
bookmark.bandwidth_hz.unwrap_or(0) as u32,
|
||||
bookmark_decoder_kinds(&bookmark),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
vchan_mgr.sync_scheduler_channels(rig_id, &desired);
|
||||
}
|
||||
|
||||
fn bookmark_decoder_kinds(bookmark: &crate::server::bookmarks::Bookmark) -> Vec<String> {
|
||||
trx_protocol::decoders::resolve_bookmark_decoders(&bookmark.decoders, &bookmark.mode, true)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /events SSE endpoint
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct EventsQuery {
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/events")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn events(
|
||||
query: web::Query<EventsQuery>,
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
clients: web::Data<Arc<AtomicUsize>>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
bookmark_store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
scheduler_status: web::Data<crate::server::scheduler::SchedulerStatusMap>,
|
||||
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
|
||||
session_rig_mgr: web::Data<Arc<SessionRigManager>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let counter = clients.get_ref().clone();
|
||||
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
// Assign a stable UUID to this SSE session for channel binding.
|
||||
let session_id = Uuid::new_v4();
|
||||
scheduler_control.register_session(session_id);
|
||||
|
||||
// Use the client-requested remote if provided, otherwise fall back to
|
||||
// the global default.
|
||||
let active_rig_id = query.remote.clone().filter(|s| !s.is_empty()).or_else(|| {
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
});
|
||||
|
||||
// Subscribe to the per-rig watch channel for this session's rig.
|
||||
let rx = active_rig_id
|
||||
.as_deref()
|
||||
.and_then(|rid| context.rig_state_rx(rid))
|
||||
.unwrap_or_else(|| state.get_ref().clone());
|
||||
let initial = wait_for_view(rx.clone()).await?;
|
||||
if let Some(ref rid) = active_rig_id {
|
||||
session_rig_mgr.register(session_id, rid.clone());
|
||||
vchan_mgr.init_rig(
|
||||
rid,
|
||||
initial.status.freq.hz,
|
||||
&format!("{:?}", initial.status.mode),
|
||||
);
|
||||
sync_scheduler_vchannels(
|
||||
vchan_mgr.get_ref().as_ref(),
|
||||
bookmark_store_map.get_ref().as_ref(),
|
||||
scheduler_status.get_ref(),
|
||||
scheduler_control.get_ref().as_ref(),
|
||||
rid,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the prefix burst: rig state → session UUID → initial channels.
|
||||
let initial_combined = SnapshotWithMeta {
|
||||
snapshot: &initial,
|
||||
meta: frontend_meta_from_context(
|
||||
count,
|
||||
context.get_ref().as_ref(),
|
||||
active_rig_id.as_deref(),
|
||||
),
|
||||
};
|
||||
let initial_json = serde_json::to_string(&initial_combined)
|
||||
.map_err(actix_web::error::ErrorInternalServerError)?;
|
||||
|
||||
let mut prefix: Vec<Result<Bytes, Error>> = Vec::new();
|
||||
prefix.push(Ok(Bytes::from(format!("data: {initial_json}\n\n"))));
|
||||
prefix.push(Ok(Bytes::from(format!(
|
||||
"event: session\ndata: {{\"session_id\":\"{session_id}\"}}\n\n"
|
||||
))));
|
||||
if let Some(ref rid) = active_rig_id {
|
||||
let chans = vchan_mgr.channels(rid);
|
||||
if let Ok(json) = serde_json::to_string(&chans) {
|
||||
prefix.push(Ok(Bytes::from(format!(
|
||||
"event: channels\ndata: {{\"remote\":\"{rid}\",\"channels\":{json}}}\n\n"
|
||||
))));
|
||||
}
|
||||
}
|
||||
let prefix_stream = futures_util::stream::iter(prefix);
|
||||
|
||||
// Live rig-state updates; side-effect: keep primary channel metadata in sync.
|
||||
let counter_updates = counter.clone();
|
||||
let context_updates = context.get_ref().clone();
|
||||
let vchan_updates = vchan_mgr.get_ref().clone();
|
||||
let bookmark_store_map_updates = bookmark_store_map.get_ref().clone();
|
||||
let scheduler_status_updates = scheduler_status.get_ref().clone();
|
||||
let scheduler_control_updates = scheduler_control.get_ref().clone();
|
||||
let session_rig_mgr_updates = session_rig_mgr.get_ref().clone();
|
||||
let updates = WatchStream::new(rx).filter_map(move |state| {
|
||||
let counter = counter_updates.clone();
|
||||
let context = context_updates.clone();
|
||||
let vchan = vchan_updates.clone();
|
||||
let bookmark_store_map = bookmark_store_map_updates.clone();
|
||||
let scheduler_status = scheduler_status_updates.clone();
|
||||
let scheduler_control = scheduler_control_updates.clone();
|
||||
let session_rig_mgr = session_rig_mgr_updates.clone();
|
||||
async move {
|
||||
state.snapshot().and_then(|v| {
|
||||
let rig_id_opt = session_rig_mgr.get_rig(session_id).or_else(|| {
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
});
|
||||
if let Some(ref rig_id) = rig_id_opt {
|
||||
vchan.update_primary(rig_id, v.status.freq.hz, &format!("{:?}", v.status.mode));
|
||||
sync_scheduler_vchannels(
|
||||
vchan.as_ref(),
|
||||
bookmark_store_map.as_ref(),
|
||||
&scheduler_status,
|
||||
scheduler_control.as_ref(),
|
||||
rig_id,
|
||||
);
|
||||
}
|
||||
let combined = SnapshotWithMeta {
|
||||
snapshot: &v,
|
||||
meta: frontend_meta_from_context(
|
||||
counter.load(Ordering::Relaxed),
|
||||
context.as_ref(),
|
||||
rig_id_opt.as_deref(),
|
||||
),
|
||||
};
|
||||
serde_json::to_string(&combined)
|
||||
.ok()
|
||||
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Channel-list change events from the virtual channel manager.
|
||||
let vchan_change_rx = vchan_mgr.change_tx.subscribe();
|
||||
let session_rig_for_chan = active_rig_id.clone();
|
||||
let chan_updates = futures_util::stream::unfold(
|
||||
(vchan_change_rx, session_rig_for_chan),
|
||||
|(mut rx, srig)| async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => {
|
||||
if let Some(colon) = msg.find(':') {
|
||||
let rig_id = &msg[..colon];
|
||||
if let Some(ref expected) = srig {
|
||||
if rig_id != expected.as_str() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let channels_json = &msg[colon + 1..];
|
||||
let payload =
|
||||
format!("{{\"remote\":\"{rig_id}\",\"channels\":{channels_json}}}");
|
||||
return Some((
|
||||
Ok::<Bytes, Error>(Bytes::from(format!(
|
||||
"event: channels\ndata: {payload}\n\n"
|
||||
))),
|
||||
(rx, srig),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Send a named "ping" event so the JS heartbeat can observe it.
|
||||
let pings = IntervalStream::new(time::interval(Duration::from_secs(5)))
|
||||
.map(|_| Ok::<Bytes, Error>(Bytes::from("event: ping\ndata: \n\n")));
|
||||
|
||||
let vchan_drop = vchan_mgr.get_ref().clone();
|
||||
let counter_drop = counter.clone();
|
||||
let scheduler_control_drop = scheduler_control.get_ref().clone();
|
||||
let session_rig_mgr_drop = session_rig_mgr.get_ref().clone();
|
||||
let live = select(select(pings, updates), chan_updates);
|
||||
let stream = prefix_stream.chain(live);
|
||||
let stream = DropStream::new(Box::pin(stream), move || {
|
||||
counter_drop.fetch_sub(1, Ordering::Relaxed);
|
||||
vchan_drop.release_session(session_id);
|
||||
scheduler_control_drop.unregister_session(session_id);
|
||||
session_rig_mgr_drop.unregister(session_id);
|
||||
});
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /meter SSE endpoint (fast signal-strength stream, ~30 Hz)
|
||||
// ============================================================================
|
||||
|
||||
fn encode_meter_frame(update: &MeterUpdate) -> String {
|
||||
// Compact JSON: one-line SSE frame, flushed immediately.
|
||||
// Shape: {"sig":-72.3,"ts":12345}
|
||||
format!(
|
||||
"data: {{\"sig\":{:.2},\"ts\":{}}}\n\n",
|
||||
update.sig_dbm, update.ts_ms
|
||||
)
|
||||
}
|
||||
|
||||
/// SSE stream for per-rig signal-strength updates.
|
||||
///
|
||||
/// Pushed from the server's per-rig meter broadcast; intentionally bypasses
|
||||
/// the `/events` RigState path so high-rate meter samples are never gated by
|
||||
/// full-state diffing. Each watch update produces exactly one SSE frame.
|
||||
#[get("/meter")]
|
||||
pub async fn meter(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let rig_id = query.remote.clone().filter(|s| !s.is_empty()).or_else(|| {
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
});
|
||||
|
||||
let rx = match rig_id.as_deref() {
|
||||
Some(rid) => context.rig_meter_rx(rid),
|
||||
None => return Ok(HttpResponse::NotFound().finish()),
|
||||
};
|
||||
|
||||
let updates = WatchStream::new(rx).filter_map(|maybe| {
|
||||
let chunk = maybe.as_ref().map(encode_meter_frame);
|
||||
std::future::ready(chunk.map(|s| Ok::<Bytes, Error>(Bytes::from(s))))
|
||||
});
|
||||
|
||||
// Infrequent keepalive comment; real meter frames carry the heartbeat.
|
||||
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
|
||||
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
|
||||
|
||||
let stream = select(pings, updates);
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /spectrum SSE endpoint
|
||||
// ============================================================================
|
||||
|
||||
/// SSE stream for spectrum data.
|
||||
#[get("/spectrum")]
|
||||
pub async fn spectrum(
|
||||
query: web::Query<RemoteQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let rx = if let Some(ref remote) = query.remote {
|
||||
context.rig_spectrum_rx(remote)
|
||||
} else {
|
||||
context.spectrum.sender.subscribe()
|
||||
};
|
||||
let mut last_rds_json: Option<String> = None;
|
||||
let mut last_vchan_rds_json: Option<String> = None;
|
||||
let mut last_had_frame = false;
|
||||
let updates = WatchStream::new(rx).filter_map(move |snapshot| {
|
||||
let sse_chunk: Option<String> = if let Some(ref frame) = snapshot.frame {
|
||||
last_had_frame = true;
|
||||
let mut chunk = format!("event: b\ndata: {}\n\n", encode_spectrum_frame(frame));
|
||||
if snapshot.rds_json != last_rds_json {
|
||||
let data = snapshot.rds_json.as_deref().unwrap_or("null");
|
||||
chunk.push_str(&format!("event: rds\ndata: {data}\n\n"));
|
||||
last_rds_json = snapshot.rds_json;
|
||||
}
|
||||
if snapshot.vchan_rds_json != last_vchan_rds_json {
|
||||
let data = snapshot.vchan_rds_json.as_deref().unwrap_or("null");
|
||||
chunk.push_str(&format!("event: rds_vchan\ndata: {data}\n\n"));
|
||||
last_vchan_rds_json = snapshot.vchan_rds_json;
|
||||
}
|
||||
Some(chunk)
|
||||
} else if last_had_frame {
|
||||
last_had_frame = false;
|
||||
Some("data: null\n\n".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
std::future::ready(sse_chunk.map(|s| Ok::<Bytes, Error>(Bytes::from(s))))
|
||||
});
|
||||
|
||||
let pings = IntervalStream::new(time::interval(Duration::from_secs(15)))
|
||||
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
|
||||
|
||||
let stream = select(pings, updates);
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
|
||||
.insert_header((header::CONTENT_ENCODING, "identity"))
|
||||
.insert_header((header::CACHE_CONTROL, "no-cache"))
|
||||
.insert_header((header::CONNECTION, "keep-alive"))
|
||||
.streaming(stream))
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Virtual channel management endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::Error;
|
||||
use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::{RigCommand, RigRequest};
|
||||
use trx_protocol::parse_mode;
|
||||
|
||||
use crate::server::vchan::ClientChannelManager;
|
||||
|
||||
use super::send_command_to_rig;
|
||||
|
||||
// ============================================================================
|
||||
// Channel CRUD
|
||||
// ============================================================================
|
||||
|
||||
#[get("/channels/{remote}")]
|
||||
pub async fn list_channels(
|
||||
path: web::Path<String>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let remote = path.into_inner();
|
||||
HttpResponse::Ok().json(vchan_mgr.channels(&remote))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AllocateChannelBody {
|
||||
session_id: Uuid,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[post("/channels/{remote}")]
|
||||
pub async fn allocate_channel(
|
||||
path: web::Path<String>,
|
||||
body: web::Json<AllocateChannelBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let remote = path.into_inner();
|
||||
match vchan_mgr.allocate(body.session_id, &remote, body.freq_hz, &body.mode) {
|
||||
Ok(ch) => HttpResponse::Ok().json(ch),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/channels/{remote}/{channel_id}")]
|
||||
pub async fn delete_channel_route(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.delete_channel(&remote, channel_id) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::Permanent) => {
|
||||
HttpResponse::BadRequest().body("cannot remove the primary channel")
|
||||
}
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SubscribeBody {
|
||||
session_id: Uuid,
|
||||
}
|
||||
|
||||
#[post("/channels/{remote}/{channel_id}/subscribe")]
|
||||
pub async fn subscribe_channel(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SubscribeBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||
bookmark_store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||
scheduler_control: web::Data<crate::server::scheduler::SharedSchedulerControlManager>,
|
||||
) -> impl Responder {
|
||||
let body = body.into_inner();
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.subscribe_session(body.session_id, &remote, channel_id) {
|
||||
Some(ch) => {
|
||||
scheduler_control.set_released(body.session_id, false);
|
||||
let Some(selected) = vchan_mgr.selected_channel(&remote, channel_id) else {
|
||||
return HttpResponse::InternalServerError().body("subscribed channel missing");
|
||||
};
|
||||
if let Err(err) = apply_selected_channel(
|
||||
rig_tx.get_ref(),
|
||||
&remote,
|
||||
&selected,
|
||||
bookmark_store_map.get_ref().as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return HttpResponse::from_error(err);
|
||||
}
|
||||
HttpResponse::Ok().json(ch)
|
||||
}
|
||||
None => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel property updates
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetChanFreqBody {
|
||||
freq_hz: u64,
|
||||
}
|
||||
|
||||
#[put("/channels/{remote}/{channel_id}/freq")]
|
||||
pub async fn set_vchan_freq(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SetChanFreqBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.set_channel_freq(&remote, channel_id, body.freq_hz) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetChanBwBody {
|
||||
bandwidth_hz: u32,
|
||||
}
|
||||
|
||||
#[put("/channels/{remote}/{channel_id}/bw")]
|
||||
pub async fn set_vchan_bw(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SetChanBwBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.set_channel_bandwidth(&remote, channel_id, body.bandwidth_hz) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SetChanModeBody {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[put("/channels/{remote}/{channel_id}/mode")]
|
||||
pub async fn set_vchan_mode(
|
||||
path: web::Path<(String, Uuid)>,
|
||||
body: web::Json<SetChanModeBody>,
|
||||
vchan_mgr: web::Data<Arc<ClientChannelManager>>,
|
||||
) -> impl Responder {
|
||||
let (remote, channel_id) = path.into_inner();
|
||||
match vchan_mgr.set_channel_mode(&remote, channel_id, &body.mode) {
|
||||
Ok(()) => HttpResponse::Ok().finish(),
|
||||
Err(crate::server::vchan::VChanClientError::NotFound) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn bookmark_decoder_state(
|
||||
bookmark: &crate::server::bookmarks::Bookmark,
|
||||
) -> (bool, bool, bool, bool, bool, bool, bool, bool) {
|
||||
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
|
||||
let mut want_hf_aprs = false;
|
||||
let mut want_ft8 = false;
|
||||
let mut want_ft4 = false;
|
||||
let mut want_ft2 = false;
|
||||
let mut want_wspr = false;
|
||||
let mut want_lrpt = false;
|
||||
let mut want_wefax = false;
|
||||
|
||||
for decoder in bookmark
|
||||
.decoders
|
||||
.iter()
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
{
|
||||
match decoder.as_str() {
|
||||
"aprs" => want_aprs = true,
|
||||
"hf-aprs" => want_hf_aprs = true,
|
||||
"ft8" => want_ft8 = true,
|
||||
"ft4" => want_ft4 = true,
|
||||
"ft2" => want_ft2 = true,
|
||||
"wspr" => want_wspr = true,
|
||||
"lrpt" => want_lrpt = true,
|
||||
"wefax" => want_wefax = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
want_aprs,
|
||||
want_hf_aprs,
|
||||
want_ft8,
|
||||
want_ft4,
|
||||
want_ft2,
|
||||
want_wspr,
|
||||
want_lrpt,
|
||||
want_wefax,
|
||||
)
|
||||
}
|
||||
|
||||
async fn apply_selected_channel(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
remote: &str,
|
||||
channel: &crate::server::vchan::SelectedChannel,
|
||||
bookmark_store_map: &crate::server::bookmarks::BookmarkStoreMap,
|
||||
) -> Result<(), Error> {
|
||||
send_command_to_rig(
|
||||
rig_tx,
|
||||
remote,
|
||||
RigCommand::SetMode(parse_mode(&channel.mode)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if channel.bandwidth_hz > 0 {
|
||||
send_command_to_rig(
|
||||
rig_tx,
|
||||
remote,
|
||||
RigCommand::SetBandwidth(channel.bandwidth_hz),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
send_command_to_rig(
|
||||
rig_tx,
|
||||
remote,
|
||||
RigCommand::SetFreq(Freq {
|
||||
hz: channel.freq_hz,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(bookmark_id) = channel.scheduler_bookmark_id.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(bookmark) = bookmark_store_map.get_for_rig(remote, bookmark_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_lrpt, want_wefax) =
|
||||
bookmark_decoder_state(&bookmark);
|
||||
let desired = [
|
||||
RigCommand::SetAprsDecodeEnabled(want_aprs),
|
||||
RigCommand::SetHfAprsDecodeEnabled(want_hf_aprs),
|
||||
RigCommand::SetFt8DecodeEnabled(want_ft8),
|
||||
RigCommand::SetFt4DecodeEnabled(want_ft4),
|
||||
RigCommand::SetFt2DecodeEnabled(want_ft2),
|
||||
RigCommand::SetWsprDecodeEnabled(want_wspr),
|
||||
RigCommand::SetLrptDecodeEnabled(want_lrpt),
|
||||
RigCommand::SetWefaxDecodeEnabled(want_wefax),
|
||||
];
|
||||
for cmd in desired {
|
||||
send_command_to_rig(rig_tx, remote, cmd).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Audio WebSocket endpoint for the HTTP frontend.
|
||||
//!
|
||||
//! Exposes `/audio` which upgrades to a WebSocket:
|
||||
//! - First text message: JSON `AudioStreamInfo`
|
||||
//! - Subsequent binary messages: raw Opus packets (RX)
|
||||
//! - Browser sends binary messages: raw Opus packets (TX)
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
|
||||
use actix_ws::Message;
|
||||
use base64::Engine as _;
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
};
|
||||
use trx_frontend::FrontendRuntimeContext;
|
||||
|
||||
fn current_timestamp_ms() -> i64 {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
i64::try_from(millis).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn decode_history_retention(context: &FrontendRuntimeContext) -> Duration {
|
||||
let default_minutes = context.http_ui.decode_history_retention_min.max(1);
|
||||
let minutes = context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|v| v.clone())
|
||||
.and_then(|rig_id| {
|
||||
context
|
||||
.http_ui
|
||||
.decode_history_retention_min_by_rig
|
||||
.get(&rig_id)
|
||||
.copied()
|
||||
})
|
||||
.filter(|minutes| *minutes > 0)
|
||||
.unwrap_or(default_minutes);
|
||||
Duration::from_secs(minutes.saturating_mul(60))
|
||||
}
|
||||
|
||||
fn decode_history_cutoff(context: &FrontendRuntimeContext) -> Instant {
|
||||
Instant::now() - decode_history_retention(context)
|
||||
}
|
||||
|
||||
fn prune_aprs_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, AprsPacket)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_hf_aprs_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, AprsPacket)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_ais_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, AisMessage)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_vdes_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, VdesMessage)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn active_rig_id(context: &FrontendRuntimeContext) -> Option<String> {
|
||||
context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
fn record_ais(context: &FrontendRuntimeContext, mut msg: AisMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ais
|
||||
.lock()
|
||||
.expect("ais history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
prune_ais_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_vdes(context: &FrontendRuntimeContext, mut msg: VdesMessage) {
|
||||
if msg.ts_ms.is_none() {
|
||||
msg.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.vdes
|
||||
.lock()
|
||||
.expect("vdes history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
prune_vdes_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn prune_cw_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, CwEvent)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_ft8_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, Ft8Message)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_ft4_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, Ft8Message)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_ft2_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, Ft8Message)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_wspr_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
history: &mut VecDeque<(Instant, Option<String>, WsprMessage)>,
|
||||
) {
|
||||
let cutoff = decode_history_cutoff(context);
|
||||
while let Some((ts, _, _)) = history.front() {
|
||||
if *ts >= cutoff {
|
||||
break;
|
||||
}
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn record_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
|
||||
if pkt.ts_ms.is_none() {
|
||||
pkt.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let rig_id = pkt.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.aprs
|
||||
.lock()
|
||||
.expect("aprs history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, pkt));
|
||||
prune_aprs_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
|
||||
if pkt.ts_ms.is_none() {
|
||||
pkt.ts_ms = Some(current_timestamp_ms());
|
||||
}
|
||||
let rig_id = pkt.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.hf_aprs
|
||||
.lock()
|
||||
.expect("hf_aprs history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, pkt));
|
||||
prune_hf_aprs_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
|
||||
let rig_id = event.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.cw
|
||||
.lock()
|
||||
.expect("cw history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, event));
|
||||
prune_cw_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_ft8(context: &FrontendRuntimeContext, msg: Ft8Message) {
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft8
|
||||
.lock()
|
||||
.expect("ft8 history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
prune_ft8_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_ft4(context: &FrontendRuntimeContext, msg: Ft8Message) {
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft4
|
||||
.lock()
|
||||
.expect("ft4 history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
prune_ft4_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_ft2(context: &FrontendRuntimeContext, msg: Ft8Message) {
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft2
|
||||
.lock()
|
||||
.expect("ft2 history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
prune_ft2_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.wspr
|
||||
.lock()
|
||||
.expect("wspr history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
prune_wspr_history(context, &mut history);
|
||||
}
|
||||
|
||||
fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
|
||||
// If the server sent PNG data, save it to the local cache so the
|
||||
// `/images/` endpoint can serve it.
|
||||
if let Some(ref data) = msg.png_data {
|
||||
if let Some(ref path) = msg.path {
|
||||
if let Some(filename) = std::path::Path::new(path).file_name() {
|
||||
let dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("wefax");
|
||||
if std::fs::create_dir_all(&dir).is_ok() {
|
||||
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
|
||||
let local_path = dir.join(filename);
|
||||
if let Err(e) = std::fs::write(&local_path, &bytes) {
|
||||
tracing::warn!("WEFAX: failed to save local image: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strip bulk data before storing in memory.
|
||||
msg.png_data = None;
|
||||
|
||||
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.wefax
|
||||
.lock()
|
||||
.expect("wefax history mutex poisoned");
|
||||
history.push_back((Instant::now(), rig_id, msg));
|
||||
// Wefax images are large; keep a small history.
|
||||
while history.len() > 100 {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the entry's rig_id matches the optional filter.
|
||||
/// `None` filter means "all rigs".
|
||||
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
|
||||
match filter {
|
||||
None => true,
|
||||
Some(f) => entry_rig == Some(f),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot_aprs_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<AprsPacket> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.aprs
|
||||
.lock()
|
||||
.expect("aprs history mutex poisoned");
|
||||
prune_aprs_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, pkt)| pkt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_hf_aprs_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<AprsPacket> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.hf_aprs
|
||||
.lock()
|
||||
.expect("hf_aprs history mutex poisoned");
|
||||
prune_hf_aprs_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, pkt)| pkt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return the latest message per MMSI seen within the retention window.
|
||||
///
|
||||
/// AIS vessels transmit every 2–30 s; returning every individual message would
|
||||
/// produce a response too large to be useful. One entry per vessel matches
|
||||
/// what the map shows (current position/state) and keeps the response compact.
|
||||
/// The returned vec is sorted ascending by `ts_ms` so the client can replay
|
||||
/// in chronological order.
|
||||
pub fn snapshot_ais_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<AisMessage> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ais
|
||||
.lock()
|
||||
.expect("ais history mutex poisoned");
|
||||
prune_ais_history(context, &mut history);
|
||||
// Iterate oldest-first; later entries overwrite earlier ones so the
|
||||
// HashMap always holds the newest message per MMSI.
|
||||
let mut latest: HashMap<u32, AisMessage> = HashMap::new();
|
||||
for (_, rid, msg) in history.iter() {
|
||||
if matches_rig_filter(rid.as_deref(), rig_filter) {
|
||||
latest.insert(msg.mmsi, msg.clone());
|
||||
}
|
||||
}
|
||||
let mut out: Vec<AisMessage> = latest.into_values().collect();
|
||||
out.sort_by_key(|m| m.ts_ms.unwrap_or(0));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn snapshot_vdes_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<VdesMessage> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.vdes
|
||||
.lock()
|
||||
.expect("vdes history mutex poisoned");
|
||||
prune_vdes_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_cw_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<CwEvent> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.cw
|
||||
.lock()
|
||||
.expect("cw history mutex poisoned");
|
||||
prune_cw_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, evt)| evt.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_ft8_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<Ft8Message> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft8
|
||||
.lock()
|
||||
.expect("ft8 history mutex poisoned");
|
||||
prune_ft8_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_ft4_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<Ft8Message> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft4
|
||||
.lock()
|
||||
.expect("ft4 history mutex poisoned");
|
||||
prune_ft4_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_ft2_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<Ft8Message> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft2
|
||||
.lock()
|
||||
.expect("ft2 history mutex poisoned");
|
||||
prune_ft2_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_wspr_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<WsprMessage> {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.wspr
|
||||
.lock()
|
||||
.expect("wspr history mutex poisoned");
|
||||
prune_wspr_history(context, &mut history);
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_wefax_history(
|
||||
context: &FrontendRuntimeContext,
|
||||
rig_filter: Option<&str>,
|
||||
) -> Vec<WefaxMessage> {
|
||||
let history = context
|
||||
.decode_history
|
||||
.wefax
|
||||
.lock()
|
||||
.expect("wefax history mutex poisoned");
|
||||
history
|
||||
.iter()
|
||||
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||
.map(|(_, _, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.wefax
|
||||
.lock()
|
||||
.expect("wefax history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.aprs
|
||||
.lock()
|
||||
.expect("aprs history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_hf_aprs_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.hf_aprs
|
||||
.lock()
|
||||
.expect("hf_aprs history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_ais_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ais
|
||||
.lock()
|
||||
.expect("ais history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_vdes_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.vdes
|
||||
.lock()
|
||||
.expect("vdes history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_cw_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.cw
|
||||
.lock()
|
||||
.expect("cw history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_ft8_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft8
|
||||
.lock()
|
||||
.expect("ft8 history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_ft4_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft4
|
||||
.lock()
|
||||
.expect("ft4 history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_ft2_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.ft2
|
||||
.lock()
|
||||
.expect("ft2 history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn clear_wspr_history(context: &FrontendRuntimeContext) {
|
||||
let mut history = context
|
||||
.decode_history
|
||||
.wspr
|
||||
.lock()
|
||||
.expect("wspr history mutex poisoned");
|
||||
history.clear();
|
||||
}
|
||||
|
||||
pub fn subscribe_decode(
|
||||
context: &FrontendRuntimeContext,
|
||||
) -> Option<broadcast::Receiver<DecodedMessage>> {
|
||||
context.audio.decode_rx.as_ref().map(|tx| tx.subscribe())
|
||||
}
|
||||
|
||||
pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
||||
if context
|
||||
.decode_collector_started
|
||||
.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(tx) = context.audio.decode_rx.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut rx = tx.subscribe();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => match msg {
|
||||
DecodedMessage::Ais(msg) => record_ais(&context, msg),
|
||||
DecodedMessage::Vdes(msg) => record_vdes(&context, msg),
|
||||
DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt),
|
||||
DecodedMessage::HfAprs(pkt) => record_hf_aprs(&context, pkt),
|
||||
DecodedMessage::Cw(evt) => record_cw(&context, evt),
|
||||
DecodedMessage::Ft8(msg) => record_ft8(&context, msg),
|
||||
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
|
||||
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
|
||||
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
||||
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
|
||||
DecodedMessage::WefaxProgress(_) => {}
|
||||
DecodedMessage::LrptImage(_) => {}
|
||||
DecodedMessage::LrptProgress(_) => {}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AudioQuery {
|
||||
pub channel_id: Option<Uuid>,
|
||||
pub remote: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/audio")]
|
||||
pub async fn audio_ws(
|
||||
req: HttpRequest,
|
||||
body: web::Payload,
|
||||
query: web::Query<AudioQuery>,
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
|
||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||
};
|
||||
|
||||
// Plain GET probe (no WebSocket upgrade) - return 204 to signal audio is available.
|
||||
if !req.headers().contains_key("upgrade") {
|
||||
return Ok(HttpResponse::NoContent().finish());
|
||||
}
|
||||
|
||||
// If a channel_id is specified, subscribe to the per-channel broadcaster.
|
||||
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
|
||||
// from the server, which may lag the HTTP allocation by up to ~100 ms.
|
||||
// Poll for up to 2 s so a tight JS timer doesn't race and get a 404.
|
||||
let (rx_sub, mut info_rx): (
|
||||
broadcast::Receiver<Bytes>,
|
||||
tokio::sync::watch::Receiver<Option<trx_core::audio::AudioStreamInfo>>,
|
||||
) = if let Some(ch_id) = query.channel_id {
|
||||
let info_rx = if let Some(ref remote) = query.remote {
|
||||
context.rig_audio_info_rx(remote)
|
||||
} else {
|
||||
context.audio.info.as_ref().cloned()
|
||||
};
|
||||
let Some(info_rx) = info_rx else {
|
||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||
};
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let rx_sub = loop {
|
||||
match context.vchan.audio.read() {
|
||||
Ok(map) => {
|
||||
if let Some(tx) = map.get(&ch_id) {
|
||||
break tx.subscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => return Ok(HttpResponse::InternalServerError().finish()),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(HttpResponse::NotFound().body("channel not found"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
(rx_sub, info_rx)
|
||||
} else if let Some(ref remote) = query.remote {
|
||||
// Per-rig audio: subscribe to the specific rig's broadcast.
|
||||
// Do NOT fall back to global — that would silently deliver the wrong
|
||||
// rig's audio. Wait briefly for the per-rig channel to appear (it is
|
||||
// lazily created by the audio relay sync task every 500ms).
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let (rx_sub, info_rx) = loop {
|
||||
if let (Some(rx), Some(info_rx)) = (
|
||||
context.rig_audio_subscribe(remote),
|
||||
context.rig_audio_info_rx(remote),
|
||||
) {
|
||||
break (rx, info_rx);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(
|
||||
HttpResponse::NotFound().body(format!("audio not available for rig {remote}"))
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
};
|
||||
(rx_sub, info_rx)
|
||||
} else {
|
||||
let Some(info_rx) = context.audio.info.as_ref().cloned() else {
|
||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||
};
|
||||
let Some(rx) = context.audio.rx.as_ref() else {
|
||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||
};
|
||||
(rx.subscribe(), info_rx)
|
||||
};
|
||||
let mut rx_sub = rx_sub;
|
||||
|
||||
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?;
|
||||
|
||||
let audio_clients = context.audio.clients.clone();
|
||||
audio_clients.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
actix_web::rt::spawn(async move {
|
||||
let mut current_info = loop {
|
||||
if let Some(info) = info_rx.borrow().clone() {
|
||||
break info;
|
||||
}
|
||||
if info_rx.changed().await.is_err() {
|
||||
let _ = session.close(None).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let info_json = match serde_json::to_string(¤t_info) {
|
||||
Ok(j) => j,
|
||||
Err(_) => {
|
||||
let _ = session.close(None).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if session.text(info_json).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = info_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) => {
|
||||
let Some(next_info) = info_rx.borrow().clone() else {
|
||||
continue;
|
||||
};
|
||||
let changed = next_info.sample_rate != current_info.sample_rate
|
||||
|| next_info.channels != current_info.channels
|
||||
|| next_info.frame_duration_ms != current_info.frame_duration_ms
|
||||
|| next_info.bitrate_bps != current_info.bitrate_bps;
|
||||
if changed {
|
||||
current_info = next_info;
|
||||
let info_json = match serde_json::to_string(¤t_info) {
|
||||
Ok(j) => j,
|
||||
Err(_) => break,
|
||||
};
|
||||
if session.text(info_json).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
packet = rx_sub.recv() => {
|
||||
match packet {
|
||||
Ok(packet) => {
|
||||
if session.binary(packet).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("Audio WS: dropped {} RX frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
msg = msg_stream.recv() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(_)) | None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = session.close(None).await;
|
||||
audio_clients.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AudioQuery;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn audio_query_accepts_remote() {
|
||||
let query: AudioQuery =
|
||||
serde_json::from_str(r#"{"remote":"lidzbark-vhf"}"#).expect("query parse");
|
||||
assert_eq!(query.remote.as_deref(), Some("lidzbark-vhf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_query_accepts_channel_id_with_remote() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let query: AudioQuery = serde_json::from_str(&format!(
|
||||
r#"{{"channel_id":"{channel_id}","remote":"lidzbark-vhf"}}"#
|
||||
))
|
||||
.expect("query parse");
|
||||
assert_eq!(query.channel_id, Some(channel_id));
|
||||
assert_eq!(query.remote.as_deref(), Some("lidzbark-vhf"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! HTTP authentication module for trx-frontend-http.
|
||||
//!
|
||||
//! Provides optional session-based authentication with two roles:
|
||||
//! - `Rx`: read-only access to status/events/audio
|
||||
//! - `Control`: full access including TX/PTT control
|
||||
|
||||
use actix_web::{
|
||||
cookie::Cookie,
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
get, post, web, Error, HttpRequest, HttpResponse, Responder,
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
use tracing::warn;
|
||||
|
||||
/// Unique session identifier (hex-encoded 128-bit random)
|
||||
pub type SessionId = String;
|
||||
|
||||
/// Authentication role
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthRole {
|
||||
/// Read-only access (rx passphrase)
|
||||
Rx,
|
||||
/// Full control access (control passphrase)
|
||||
Control,
|
||||
}
|
||||
|
||||
impl AuthRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Rx => "rx",
|
||||
Self::Control => "control",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session record stored in the session store
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRecord {
|
||||
pub role: AuthRole,
|
||||
pub issued_at: SystemTime,
|
||||
pub expires_at: SystemTime,
|
||||
pub last_seen: SystemTime,
|
||||
}
|
||||
|
||||
impl SessionRecord {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
SystemTime::now() > self.expires_at
|
||||
}
|
||||
|
||||
pub fn update_last_seen(&mut self) {
|
||||
self.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe in-memory session store
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<RwLock<HashMap<SessionId, SessionRecord>>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new session with the given role and TTL
|
||||
pub fn create(&self, role: AuthRole, ttl: Duration) -> SessionId {
|
||||
let now = SystemTime::now();
|
||||
let expires_at = now + ttl;
|
||||
let session_id = Self::generate_session_id();
|
||||
|
||||
let record = SessionRecord {
|
||||
role,
|
||||
issued_at: now,
|
||||
expires_at,
|
||||
last_seen: now,
|
||||
};
|
||||
|
||||
let mut store = self.sessions.write().unwrap_or_else(|e| {
|
||||
warn!("Session store lock poisoned (create), recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
store.insert(session_id.clone(), record);
|
||||
session_id
|
||||
}
|
||||
|
||||
/// Get session by ID (returns None if expired or not found)
|
||||
pub fn get(&self, session_id: &SessionId) -> Option<SessionRecord> {
|
||||
let mut store = self.sessions.write().unwrap_or_else(|e| {
|
||||
warn!("Session store lock poisoned (get), recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
if let Some(record) = store.get_mut(session_id) {
|
||||
if !record.is_expired() {
|
||||
record.update_last_seen();
|
||||
return Some(record.clone());
|
||||
} else {
|
||||
store.remove(session_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Invalidate a session
|
||||
pub fn remove(&self, session_id: &SessionId) {
|
||||
let mut store = self.sessions.write().unwrap_or_else(|e| {
|
||||
warn!("Session store lock poisoned (remove), recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
store.remove(session_id);
|
||||
}
|
||||
|
||||
/// Remove all expired sessions
|
||||
pub fn cleanup_expired(&self) {
|
||||
let mut store = self.sessions.write().unwrap_or_else(|e| {
|
||||
warn!("Session store lock poisoned (cleanup), recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
let now = SystemTime::now();
|
||||
store.retain(|_, record| record.expires_at > now);
|
||||
}
|
||||
|
||||
/// Generate a new random session ID (128-bit, hex-encoded)
|
||||
fn generate_session_id() -> SessionId {
|
||||
let random_bytes = rand::random::<[u8; 16]>();
|
||||
hex::encode(random_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookie SameSite attribute
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SameSite {
|
||||
Strict,
|
||||
#[default]
|
||||
Lax,
|
||||
None,
|
||||
}
|
||||
|
||||
impl SameSite {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Strict => "Strict",
|
||||
Self::Lax => "Lax",
|
||||
Self::None => "None",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime authentication configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub enabled: bool,
|
||||
pub rx_passphrase: Option<String>,
|
||||
pub control_passphrase: Option<String>,
|
||||
pub tx_access_control_enabled: bool,
|
||||
pub session_ttl: Duration,
|
||||
pub cookie_secure: bool,
|
||||
pub cookie_same_site: SameSite,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// Create a new auth config with all fields
|
||||
pub fn new(
|
||||
enabled: bool,
|
||||
rx_passphrase: Option<String>,
|
||||
control_passphrase: Option<String>,
|
||||
tx_access_control_enabled: bool,
|
||||
session_ttl: Duration,
|
||||
cookie_secure: bool,
|
||||
cookie_same_site: SameSite,
|
||||
) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
rx_passphrase,
|
||||
control_passphrase,
|
||||
tx_access_control_enabled,
|
||||
session_ttl,
|
||||
cookie_secure,
|
||||
cookie_same_site,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check passphrase and return the corresponding role
|
||||
pub fn check_passphrase(&self, passphrase: &str) -> Option<AuthRole> {
|
||||
// Use constant-time comparison to reduce timing attacks
|
||||
if let Some(ctrl_pass) = &self.control_passphrase {
|
||||
if constant_time_eq(passphrase, ctrl_pass) {
|
||||
return Some(AuthRole::Control);
|
||||
}
|
||||
}
|
||||
if let Some(rx_pass) = &self.rx_passphrase {
|
||||
if constant_time_eq(passphrase, rx_pass) {
|
||||
return Some(AuthRole::Rx);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple per-IP rate limiter for login attempts.
|
||||
///
|
||||
/// Tracks failed attempts per IP and enforces a cooldown window after
|
||||
/// exceeding the maximum number of attempts.
|
||||
pub struct LoginRateLimiter {
|
||||
/// Maps IP → (attempt_count, window_start).
|
||||
attempts: Mutex<HashMap<String, (u32, Instant)>>,
|
||||
/// Maximum allowed attempts within the window.
|
||||
max_attempts: u32,
|
||||
/// Duration of the rate-limit window.
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl LoginRateLimiter {
|
||||
pub fn new(max_attempts: u32, window: Duration) -> Self {
|
||||
Self {
|
||||
attempts: Mutex::new(HashMap::new()),
|
||||
max_attempts,
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether an IP is rate-limited. Returns `true` if the request
|
||||
/// should be allowed, `false` if rate-limited.
|
||||
pub fn check(&self, ip: &str) -> bool {
|
||||
let mut map = self.attempts.lock().unwrap_or_else(|e| {
|
||||
warn!("Rate limiter lock poisoned (check), recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
let now = Instant::now();
|
||||
if let Some((count, window_start)) = map.get_mut(ip) {
|
||||
if now.duration_since(*window_start) > self.window {
|
||||
// Window expired, reset.
|
||||
*count = 1;
|
||||
*window_start = now;
|
||||
true
|
||||
} else if *count >= self.max_attempts {
|
||||
false
|
||||
} else {
|
||||
*count += 1;
|
||||
true
|
||||
}
|
||||
} else {
|
||||
map.insert(ip.to_string(), (1, now));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a successful login — clears the rate-limit counter for the IP.
|
||||
pub fn reset(&self, ip: &str) {
|
||||
let mut map = self.attempts.lock().unwrap_or_else(|e| {
|
||||
warn!("Rate limiter lock poisoned (reset), recovering");
|
||||
e.into_inner()
|
||||
});
|
||||
map.remove(ip);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoginRateLimiter {
|
||||
fn default() -> Self {
|
||||
// 10 attempts per 60-second window.
|
||||
Self::new(10, Duration::from_secs(60))
|
||||
}
|
||||
}
|
||||
|
||||
/// Application data for authentication
|
||||
pub struct AuthState {
|
||||
pub config: AuthConfig,
|
||||
pub store: SessionStore,
|
||||
pub rate_limiter: LoginRateLimiter,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
pub fn new(config: AuthConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
store: SessionStore::new(),
|
||||
rate_limiter: LoginRateLimiter::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time string comparison to mitigate timing attacks
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let a_bytes = a.as_bytes();
|
||||
let b_bytes = b.as_bytes();
|
||||
|
||||
if a_bytes.len() != b_bytes.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut result = 0u8;
|
||||
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
|
||||
result |= x ^ y;
|
||||
}
|
||||
result == 0
|
||||
}
|
||||
|
||||
/// Login request body
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub passphrase: String,
|
||||
}
|
||||
|
||||
/// Session status response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SessionStatus {
|
||||
pub authenticated: bool,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// Login response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub authenticated: bool,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Extract session from cookie
|
||||
fn extract_session_id(req: &HttpRequest) -> Option<SessionId> {
|
||||
req.cookie("trx_http_sid")
|
||||
.map(|cookie| cookie.value().to_string())
|
||||
}
|
||||
|
||||
/// Get session from request, return role if valid
|
||||
pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<AuthRole> {
|
||||
let session_id = extract_session_id(req)?;
|
||||
let record = auth_state.store.get(&session_id)?;
|
||||
Some(record.role)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/// POST /auth/login
|
||||
#[post("/auth/login")]
|
||||
pub async fn login(
|
||||
req: HttpRequest,
|
||||
body: web::Json<LoginRequest>,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
// Per-IP rate limiting to mitigate brute-force attacks.
|
||||
let peer_ip = req
|
||||
.peer_addr()
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
if !auth_state.rate_limiter.check(&peer_ip) {
|
||||
return Ok(HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Too many login attempts, please try again later"
|
||||
})));
|
||||
}
|
||||
|
||||
// Check passphrase
|
||||
let role = match auth_state.config.check_passphrase(&body.passphrase) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
|
||||
"error": "Invalid credentials"
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
// Successful login — clear rate limit counter.
|
||||
auth_state.rate_limiter.reset(&peer_ip);
|
||||
|
||||
// Create session
|
||||
let session_id = auth_state.store.create(role, auth_state.config.session_ttl);
|
||||
|
||||
let mut cookie = Cookie::new("trx_http_sid", session_id);
|
||||
cookie.set_path("/");
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_secure(auth_state.config.cookie_secure);
|
||||
|
||||
// Set SameSite attribute
|
||||
match auth_state.config.cookie_same_site {
|
||||
SameSite::Strict => cookie.set_same_site(actix_web::cookie::SameSite::Strict),
|
||||
SameSite::Lax => cookie.set_same_site(actix_web::cookie::SameSite::Lax),
|
||||
SameSite::None => cookie.set_same_site(actix_web::cookie::SameSite::None),
|
||||
};
|
||||
|
||||
// Convert Duration to cookie time::Duration
|
||||
let ttl_secs = auth_state.config.session_ttl.as_secs() as i64;
|
||||
cookie.set_max_age(actix_web::cookie::time::Duration::seconds(ttl_secs));
|
||||
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
|
||||
authenticated: true,
|
||||
role: role.as_str().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/logout
|
||||
#[post("/auth/logout")]
|
||||
pub async fn logout(
|
||||
req: HttpRequest,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
// Invalidate session
|
||||
if let Some(session_id) = extract_session_id(&req) {
|
||||
auth_state.store.remove(&session_id);
|
||||
}
|
||||
|
||||
// Clear cookie by setting max_age to 0
|
||||
let mut cookie = Cookie::new("trx_http_sid", "");
|
||||
cookie.set_path("/");
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_max_age(actix_web::cookie::time::Duration::seconds(0));
|
||||
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(serde_json::json!({
|
||||
"logged_out": true
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /auth/session
|
||||
#[get("/auth/session")]
|
||||
pub async fn session_status(
|
||||
req: HttpRequest,
|
||||
auth_state: web::Data<AuthState>,
|
||||
) -> Result<impl Responder, Error> {
|
||||
// If auth is disabled, grant full control access without requiring login
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: true,
|
||||
role: Some("control".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
let session_id = extract_session_id(&req);
|
||||
if let Some(session_record) = session_id.and_then(|sid| auth_state.store.get(&sid)) {
|
||||
// User has valid session
|
||||
return Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: true,
|
||||
role: Some(session_record.role.as_str().to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// No session - check if rx access is unrestricted
|
||||
if auth_state.config.rx_passphrase.is_none() {
|
||||
// No rx passphrase required - grant rx role to unauthenticated users
|
||||
return Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: false,
|
||||
role: Some("rx".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Auth required but no valid session
|
||||
Ok(HttpResponse::Ok().json(SessionStatus {
|
||||
authenticated: false,
|
||||
role: None,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Middleware
|
||||
// ============================================================================
|
||||
|
||||
/// Route classification for access control
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RouteAccess {
|
||||
/// Publicly accessible (no auth required)
|
||||
Public,
|
||||
/// Read-only (rx or control role required)
|
||||
Read,
|
||||
/// Control only (control role required)
|
||||
Control,
|
||||
}
|
||||
|
||||
impl RouteAccess {
|
||||
/// Classify a request path
|
||||
fn from_path(path: &str) -> Self {
|
||||
// Public routes
|
||||
if path == "/"
|
||||
|| path == "/index.html"
|
||||
|| path == "/map"
|
||||
|| path == "/digital-modes"
|
||||
|| path == "/settings"
|
||||
|| path == "/about"
|
||||
|| path.starts_with("/auth/")
|
||||
{
|
||||
return Self::Public;
|
||||
}
|
||||
|
||||
// Static assets
|
||||
if path.starts_with("/style.css")
|
||||
|| path.starts_with("/app.js")
|
||||
|| path.ends_with(".js")
|
||||
|| path.ends_with(".css")
|
||||
|| path.ends_with(".png")
|
||||
|| path.ends_with(".jpg")
|
||||
|| path.ends_with(".gif")
|
||||
|| path.ends_with(".svg")
|
||||
|| path.ends_with(".favicon")
|
||||
|| path.ends_with(".ico")
|
||||
{
|
||||
return Self::Public;
|
||||
}
|
||||
|
||||
// Read-only routes
|
||||
if path == "/status"
|
||||
|| path == "/rigs"
|
||||
|| path == "/events"
|
||||
|| path == "/decode"
|
||||
|| path == "/decode/history"
|
||||
|| path == "/spectrum"
|
||||
|| path == "/meter"
|
||||
|| path == "/audio"
|
||||
|| path == "/bookmarks"
|
||||
|| path.starts_with("/status?")
|
||||
|| path.starts_with("/rigs?")
|
||||
|| path.starts_with("/events?")
|
||||
|| path.starts_with("/decode?")
|
||||
|| path.starts_with("/decode/history?")
|
||||
|| path.starts_with("/spectrum?")
|
||||
|| path.starts_with("/meter?")
|
||||
|| path.starts_with("/audio?")
|
||||
|| path.starts_with("/bookmarks?")
|
||||
|| path.starts_with("/bookmarks/")
|
||||
|| path.starts_with("/scheduler/")
|
||||
|| path.starts_with("/scheduler-control")
|
||||
|| path.starts_with("/channels/")
|
||||
{
|
||||
return Self::Read;
|
||||
}
|
||||
|
||||
// All other routes require control
|
||||
Self::Control
|
||||
}
|
||||
|
||||
fn allows(&self, role: Option<AuthRole>) -> bool {
|
||||
match self {
|
||||
Self::Public => true,
|
||||
Self::Read => role.is_some(),
|
||||
Self::Control => matches!(role, Some(AuthRole::Control)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication middleware
|
||||
pub struct AuthMiddleware;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type InitError = ();
|
||||
type Transform = AuthMiddlewareService<S>;
|
||||
type Future = std::future::Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
std::future::ready(Ok(AuthMiddlewareService { service }))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthMiddlewareService<S> {
|
||||
service: S,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let path = req.path().to_string();
|
||||
let access = RouteAccess::from_path(&path);
|
||||
|
||||
// If route is public, allow unconditionally
|
||||
if access == RouteAccess::Public {
|
||||
let fut = self.service.call(req);
|
||||
return Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
});
|
||||
}
|
||||
|
||||
// For protected routes, check auth
|
||||
let auth_state = req.app_data::<web::Data<AuthState>>().cloned();
|
||||
|
||||
if let Some(auth_state) = auth_state {
|
||||
if !auth_state.config.enabled {
|
||||
// Auth disabled - allow all
|
||||
let fut = self.service.call(req);
|
||||
return Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
});
|
||||
}
|
||||
|
||||
// Auth enabled - check role
|
||||
let role = get_session_role(req.request(), &auth_state);
|
||||
|
||||
// If rx_passphrase is not set, allow unauthenticated read access
|
||||
let allow_unrestricted_read = auth_state.config.rx_passphrase.is_none();
|
||||
let is_read_route = access == RouteAccess::Read;
|
||||
|
||||
if is_read_route && allow_unrestricted_read {
|
||||
// No rx authentication required - allow read access without role
|
||||
let fut = self.service.call(req);
|
||||
return Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
});
|
||||
}
|
||||
|
||||
if !access.allows(role) {
|
||||
// Access denied
|
||||
return Box::pin(async move {
|
||||
if role.is_some() {
|
||||
// Has session but insufficient permissions - 403 Forbidden
|
||||
Err(actix_web::error::ErrorForbidden(
|
||||
"Insufficient permissions".to_string(),
|
||||
))
|
||||
} else if allow_unrestricted_read {
|
||||
// No session but rx access is unrestricted - 403 Forbidden
|
||||
// (user has implicit rx role from unrestricted access)
|
||||
Err(actix_web::error::ErrorForbidden(
|
||||
"Insufficient permissions".to_string(),
|
||||
))
|
||||
} else {
|
||||
// No session and no unrestricted access - 401 Unauthorized
|
||||
Err(actix_web::error::ErrorUnauthorized(
|
||||
"Authentication required".to_string(),
|
||||
))
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let fut = self.service.call(req);
|
||||
Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a path is a TX/PTT endpoint (used for TX access control).
|
||||
pub fn is_tx_endpoint(path: &str) -> bool {
|
||||
path.contains("ptt")
|
||||
|| path.contains("set_ptt")
|
||||
|| path.contains("toggle_ptt")
|
||||
|| path.contains("set_tx")
|
||||
|| path.contains("toggle_tx")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_route_access_public_paths() {
|
||||
assert_eq!(RouteAccess::from_path("/"), RouteAccess::Public);
|
||||
assert_eq!(RouteAccess::from_path("/map"), RouteAccess::Public);
|
||||
assert_eq!(
|
||||
RouteAccess::from_path("/digital-modes"),
|
||||
RouteAccess::Public
|
||||
);
|
||||
assert_eq!(RouteAccess::from_path("/settings"), RouteAccess::Public);
|
||||
assert_eq!(RouteAccess::from_path("/about"), RouteAccess::Public);
|
||||
assert_eq!(RouteAccess::from_path("/auth/login"), RouteAccess::Public);
|
||||
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
|
||||
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
|
||||
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_access_read_paths() {
|
||||
assert_eq!(RouteAccess::from_path("/status"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/rigs"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/events"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/decode"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/spectrum"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/meter"), RouteAccess::Read);
|
||||
assert_eq!(RouteAccess::from_path("/audio"), RouteAccess::Read);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_access_control_paths() {
|
||||
assert_eq!(RouteAccess::from_path("/set_freq"), RouteAccess::Control);
|
||||
assert_eq!(
|
||||
RouteAccess::from_path("/set_center_freq"),
|
||||
RouteAccess::Control
|
||||
);
|
||||
assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_access_allows() {
|
||||
assert!(RouteAccess::Public.allows(None));
|
||||
assert!(RouteAccess::Public.allows(Some(AuthRole::Rx)));
|
||||
assert!(RouteAccess::Public.allows(Some(AuthRole::Control)));
|
||||
|
||||
assert!(!RouteAccess::Read.allows(None));
|
||||
assert!(RouteAccess::Read.allows(Some(AuthRole::Rx)));
|
||||
assert!(RouteAccess::Read.allows(Some(AuthRole::Control)));
|
||||
|
||||
assert!(!RouteAccess::Control.allows(None));
|
||||
assert!(!RouteAccess::Control.allows(Some(AuthRole::Rx)));
|
||||
assert!(RouteAccess::Control.allows(Some(AuthRole::Control)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_store_create_and_get() {
|
||||
let store = SessionStore::new();
|
||||
let ttl = Duration::from_secs(3600);
|
||||
let session_id = store.create(AuthRole::Rx, ttl);
|
||||
|
||||
let record = store.get(&session_id);
|
||||
assert!(record.is_some());
|
||||
let record = record.unwrap();
|
||||
assert_eq!(record.role, AuthRole::Rx);
|
||||
assert!(!record.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_store_remove() {
|
||||
let store = SessionStore::new();
|
||||
let ttl = Duration::from_secs(3600);
|
||||
let session_id = store.create(AuthRole::Rx, ttl);
|
||||
|
||||
store.remove(&session_id);
|
||||
assert!(store.get(&session_id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constant_time_eq() {
|
||||
assert!(constant_time_eq("test", "test"));
|
||||
assert!(!constant_time_eq("test", "fail"));
|
||||
assert!(!constant_time_eq("test", "test2"));
|
||||
assert!(!constant_time_eq("", "test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_check_passphrase_control() {
|
||||
let config = AuthConfig {
|
||||
enabled: true,
|
||||
rx_passphrase: None,
|
||||
control_passphrase: Some("ctrl-pass".to_string()),
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl: Duration::from_secs(3600),
|
||||
cookie_secure: false,
|
||||
cookie_same_site: SameSite::Lax,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.check_passphrase("ctrl-pass"),
|
||||
Some(AuthRole::Control)
|
||||
);
|
||||
assert_eq!(config.check_passphrase("wrong"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_check_passphrase_rx() {
|
||||
let config = AuthConfig {
|
||||
enabled: true,
|
||||
rx_passphrase: Some("rx-pass".to_string()),
|
||||
control_passphrase: None,
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl: Duration::from_secs(3600),
|
||||
cookie_secure: false,
|
||||
cookie_same_site: SameSite::Lax,
|
||||
};
|
||||
|
||||
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
|
||||
assert_eq!(config.check_passphrase("wrong"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_check_passphrase_both() {
|
||||
let config = AuthConfig {
|
||||
enabled: true,
|
||||
rx_passphrase: Some("rx-pass".to_string()),
|
||||
control_passphrase: Some("ctrl-pass".to_string()),
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl: Duration::from_secs(3600),
|
||||
cookie_secure: false,
|
||||
cookie_same_site: SameSite::Lax,
|
||||
};
|
||||
|
||||
// Control is checked first
|
||||
assert_eq!(
|
||||
config.check_passphrase("ctrl-pass"),
|
||||
Some(AuthRole::Control)
|
||||
);
|
||||
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
|
||||
assert_eq!(config.check_passphrase("wrong"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_tx_endpoint() {
|
||||
assert!(is_tx_endpoint("/set_ptt"));
|
||||
assert!(is_tx_endpoint("/toggle_ptt"));
|
||||
assert!(is_tx_endpoint("/set_tx"));
|
||||
assert!(!is_tx_endpoint("/status"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use actix_web::{delete, get, put, web, HttpResponse, Responder};
|
||||
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time;
|
||||
use tracing::warn;
|
||||
use trx_frontend::{FrontendRuntimeContext, SharedSpectrum, VChanAudioCmd};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::server::bookmarks::{Bookmark, BookmarkStoreMap};
|
||||
use crate::server::scheduler::{SchedulerStatusMap, SharedSchedulerControlManager};
|
||||
use crate::server::vchan::{ClientChannel, ClientChannelManager};
|
||||
|
||||
use trx_protocol::decoders::resolve_bookmark_decoders;
|
||||
const CHANNEL_KIND_NAME: &str = "VirtualBackgroundDecodeChannel";
|
||||
const VISIBLE_CHANNEL_KIND_NAME: &str = "VirtualChannel";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct BackgroundDecodeConfig {
|
||||
pub rig_id: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub bookmark_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct BackgroundDecodeBookmarkStatus {
|
||||
pub bookmark_id: String,
|
||||
pub bookmark_name: Option<String>,
|
||||
pub freq_hz: Option<u64>,
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub decoder_kinds: Vec<String>,
|
||||
pub state: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub channel_kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct BackgroundDecodeStatus {
|
||||
pub rig_id: String,
|
||||
pub enabled: bool,
|
||||
pub active_rig: bool,
|
||||
pub center_hz: Option<u64>,
|
||||
pub sample_rate: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<BackgroundDecodeBookmarkStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct VirtualBackgroundDecodeChannel {
|
||||
uuid: Uuid,
|
||||
rig_id: String,
|
||||
bookmark_id: String,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
bandwidth_hz: u32,
|
||||
decoder_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BackgroundRuntimeState {
|
||||
current_rig_id: Option<String>,
|
||||
active_channels: HashMap<String, VirtualBackgroundDecodeChannel>,
|
||||
}
|
||||
|
||||
pub struct BackgroundDecodeStore {
|
||||
db: Arc<RwLock<PickleDb>>,
|
||||
}
|
||||
|
||||
impl BackgroundDecodeStore {
|
||||
pub fn open(path: &Path) -> Self {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let db = if path.exists() {
|
||||
PickleDb::load(
|
||||
path,
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
PickleDb::new(
|
||||
path,
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
PickleDb::new(
|
||||
path,
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
};
|
||||
Self {
|
||||
db: Arc::new(RwLock::new(db)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.map(|p| p.join("trx-rs").join("background_decode.db"))
|
||||
.unwrap_or_else(|| PathBuf::from("background_decode.db"))
|
||||
}
|
||||
|
||||
pub async fn get(&self, rig_id: &str) -> Option<BackgroundDecodeConfig> {
|
||||
let db = self.db.read().await;
|
||||
db.get::<BackgroundDecodeConfig>(&format!("bgd:{rig_id}"))
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, config: &BackgroundDecodeConfig) -> bool {
|
||||
let mut db = self.db.write().await;
|
||||
db.set(&format!("bgd:{}", config.rig_id), config).is_ok()
|
||||
}
|
||||
|
||||
pub async fn remove(&self, rig_id: &str) -> bool {
|
||||
let mut db = self.db.write().await;
|
||||
db.rem(&format!("bgd:{rig_id}")).unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BackgroundDecodeManager {
|
||||
store: Arc<BackgroundDecodeStore>,
|
||||
bookmarks: Arc<BookmarkStoreMap>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
scheduler_status: SchedulerStatusMap,
|
||||
scheduler_control: SharedSchedulerControlManager,
|
||||
vchan_mgr: Arc<ClientChannelManager>,
|
||||
status: Arc<RwLock<HashMap<String, BackgroundDecodeStatus>>>,
|
||||
notify_tx: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
impl BackgroundDecodeManager {
|
||||
pub fn new(
|
||||
store: Arc<BackgroundDecodeStore>,
|
||||
bookmarks: Arc<BookmarkStoreMap>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
scheduler_status: SchedulerStatusMap,
|
||||
scheduler_control: SharedSchedulerControlManager,
|
||||
vchan_mgr: Arc<ClientChannelManager>,
|
||||
) -> Arc<Self> {
|
||||
let (notify_tx, _) = broadcast::channel(16);
|
||||
Arc::new(Self {
|
||||
store,
|
||||
bookmarks,
|
||||
context,
|
||||
scheduler_status,
|
||||
scheduler_control,
|
||||
vchan_mgr,
|
||||
status: Arc::new(RwLock::new(HashMap::new())),
|
||||
notify_tx,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn(self: &Arc<Self>) {
|
||||
let manager = self.clone();
|
||||
tokio::spawn(async move {
|
||||
manager.run().await;
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_config(&self, rig_id: &str) -> BackgroundDecodeConfig {
|
||||
self.store
|
||||
.get(rig_id)
|
||||
.await
|
||||
.unwrap_or_else(|| BackgroundDecodeConfig {
|
||||
rig_id: rig_id.to_string(),
|
||||
enabled: false,
|
||||
bookmark_ids: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn put_config(
|
||||
&self,
|
||||
mut config: BackgroundDecodeConfig,
|
||||
) -> Option<BackgroundDecodeConfig> {
|
||||
config.bookmark_ids = dedup_ids(&config.bookmark_ids);
|
||||
if self.store.upsert(&config).await {
|
||||
self.trigger();
|
||||
Some(config)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reset_config(&self, rig_id: &str) -> bool {
|
||||
let removed = self.store.remove(rig_id).await;
|
||||
self.trigger();
|
||||
removed
|
||||
}
|
||||
|
||||
pub async fn status(&self, rig_id: &str) -> BackgroundDecodeStatus {
|
||||
{
|
||||
let status = self.status.read().await;
|
||||
if let Some(entry) = status.get(rig_id) {
|
||||
return entry.clone();
|
||||
}
|
||||
}
|
||||
let cfg = self.get_config(rig_id).await;
|
||||
let bookmarks: HashMap<String, Bookmark> = self
|
||||
.bookmarks
|
||||
.list_for_rig(rig_id)
|
||||
.into_iter()
|
||||
.map(|bookmark| (bookmark.id.clone(), bookmark))
|
||||
.collect();
|
||||
BackgroundDecodeStatus {
|
||||
rig_id: rig_id.to_string(),
|
||||
enabled: cfg.enabled,
|
||||
active_rig: self.active_rig_id().as_deref() == Some(rig_id),
|
||||
center_hz: None,
|
||||
sample_rate: None,
|
||||
entries: cfg
|
||||
.bookmark_ids
|
||||
.into_iter()
|
||||
.map(|bookmark_id| {
|
||||
let bookmark = bookmarks.get(&bookmark_id);
|
||||
BackgroundDecodeBookmarkStatus {
|
||||
bookmark_id,
|
||||
bookmark_name: bookmark.map(|item| item.name.clone()),
|
||||
freq_hz: bookmark.map(|item| item.freq_hz),
|
||||
mode: bookmark.map(|item| item.mode.clone()),
|
||||
decoder_kinds: bookmark.map(bookmark_decoder_kinds).unwrap_or_default(),
|
||||
state: "inactive".to_string(),
|
||||
channel_kind: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger(&self) {
|
||||
let _ = self.notify_tx.send(());
|
||||
}
|
||||
|
||||
fn active_rig_id(&self) -> Option<String> {
|
||||
self.context
|
||||
.routing
|
||||
.active_rig_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
fn send_audio_cmd_to_rig(&self, rig_id: &str, cmd: VChanAudioCmd) {
|
||||
// Route through per-rig sender when available.
|
||||
if let Ok(map) = self.context.vchan.rig_audio_cmd.read() {
|
||||
if let Some(tx) = map.get(rig_id) {
|
||||
let _ = tx.try_send(cmd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fall back to global sender.
|
||||
if let Ok(guard) = self.context.vchan.audio_cmd.lock() {
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
let _ = tx.try_send(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_channel(&self, channel: &VirtualBackgroundDecodeChannel) {
|
||||
self.send_audio_cmd_to_rig(&channel.rig_id, VChanAudioCmd::Remove(channel.uuid));
|
||||
}
|
||||
|
||||
fn clear_runtime_channels(&self, runtime: &mut BackgroundRuntimeState) {
|
||||
let channels: Vec<VirtualBackgroundDecodeChannel> =
|
||||
runtime.active_channels.drain().map(|(_, ch)| ch).collect();
|
||||
for channel in channels {
|
||||
self.remove_channel(&channel);
|
||||
}
|
||||
runtime.current_rig_id = None;
|
||||
}
|
||||
|
||||
fn desired_channel(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
bookmark: &Bookmark,
|
||||
decoder_kinds: Vec<String>,
|
||||
) -> VirtualBackgroundDecodeChannel {
|
||||
VirtualBackgroundDecodeChannel {
|
||||
uuid: Uuid::new_v4(),
|
||||
rig_id: rig_id.to_string(),
|
||||
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,
|
||||
decoder_kinds,
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_matches(
|
||||
channel: &VirtualBackgroundDecodeChannel,
|
||||
desired: &VirtualBackgroundDecodeChannel,
|
||||
) -> bool {
|
||||
channel.rig_id == desired.rig_id
|
||||
&& channel.bookmark_id == desired.bookmark_id
|
||||
&& channel.freq_hz == desired.freq_hz
|
||||
&& channel.mode == desired.mode
|
||||
&& channel.bandwidth_hz == desired.bandwidth_hz
|
||||
&& channel.decoder_kinds == desired.decoder_kinds
|
||||
}
|
||||
|
||||
fn virtual_channels_cover_bookmark(&self, rig_id: &str, bookmark: &Bookmark) -> bool {
|
||||
self.vchan_mgr
|
||||
.channels(rig_id)
|
||||
.into_iter()
|
||||
.any(|channel| channel_matches_bookmark(&channel, bookmark))
|
||||
}
|
||||
|
||||
async fn reconcile(&self, runtime: &mut BackgroundRuntimeState, spectrum: &SharedSpectrum) {
|
||||
let active_rig_id = self.active_rig_id();
|
||||
|
||||
if runtime.current_rig_id != active_rig_id {
|
||||
if let Some(prev_rig_id) = runtime.current_rig_id.clone() {
|
||||
let mut guard = self.status.write().await;
|
||||
if let Some(prev_status) = guard.get_mut(&prev_rig_id) {
|
||||
prev_status.active_rig = false;
|
||||
}
|
||||
}
|
||||
self.clear_runtime_channels(runtime);
|
||||
}
|
||||
|
||||
let Some(rig_id) = active_rig_id else {
|
||||
return;
|
||||
};
|
||||
runtime.current_rig_id = Some(rig_id.clone());
|
||||
|
||||
let config = self.get_config(&rig_id).await;
|
||||
let selected = dedup_ids(&config.bookmark_ids);
|
||||
let users_connected = self.context.sse_clients.load(Ordering::Relaxed) > 0;
|
||||
let scheduler_has_control = self.scheduler_control.scheduler_allowed() && users_connected;
|
||||
let scheduled_bookmark_ids = if scheduler_has_control || !users_connected {
|
||||
self.scheduler_bookmark_ids(&rig_id)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let selected_bookmarks: HashMap<String, Bookmark> = self
|
||||
.bookmarks
|
||||
.list_for_rig(&rig_id)
|
||||
.into_iter()
|
||||
.filter(|bookmark| selected.iter().any(|id| id == &bookmark.id))
|
||||
.map(|bookmark| (bookmark.id.clone(), bookmark))
|
||||
.collect();
|
||||
|
||||
let frame = spectrum.frame.as_ref().map(Arc::as_ref);
|
||||
let center_hz = frame.map(|frame| frame.center_hz);
|
||||
let sample_rate = frame.map(|frame| frame.sample_rate);
|
||||
let half_span_hz = frame.map(|frame| i64::from(frame.sample_rate) / 2);
|
||||
|
||||
let spectrum_span = match (center_hz, half_span_hz) {
|
||||
(Some(c), Some(h)) => Some((c as i64, h)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let scheduled_set: HashSet<String> = scheduled_bookmark_ids.into_iter().collect();
|
||||
|
||||
let mut statuses = Vec::new();
|
||||
let mut desired_channels = HashMap::new();
|
||||
|
||||
for bookmark_id in selected {
|
||||
let Some(bookmark) = selected_bookmarks.get(&bookmark_id) else {
|
||||
statuses.push(BackgroundDecodeBookmarkStatus {
|
||||
bookmark_id,
|
||||
state: "missing_bookmark".to_string(),
|
||||
..BackgroundDecodeBookmarkStatus::default()
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
let decoder_kinds = bookmark_decoder_kinds(bookmark);
|
||||
let mut status = BackgroundDecodeBookmarkStatus {
|
||||
bookmark_id: bookmark.id.clone(),
|
||||
bookmark_name: Some(bookmark.name.clone()),
|
||||
freq_hz: Some(bookmark.freq_hz),
|
||||
mode: Some(bookmark.mode.clone()),
|
||||
decoder_kinds: decoder_kinds.clone(),
|
||||
state: "disabled".to_string(),
|
||||
channel_kind: None,
|
||||
};
|
||||
|
||||
let vchan_covers = self.virtual_channels_cover_bookmark(&rig_id, bookmark);
|
||||
|
||||
let action = evaluate_bookmark(
|
||||
decoder_kinds.is_empty(),
|
||||
config.enabled,
|
||||
users_connected,
|
||||
scheduler_has_control,
|
||||
&scheduled_set,
|
||||
&bookmark.id,
|
||||
vchan_covers,
|
||||
spectrum_span,
|
||||
bookmark.freq_hz,
|
||||
);
|
||||
|
||||
match action {
|
||||
ChannelAction::Active => {
|
||||
status.state = "active".to_string();
|
||||
status.channel_kind = Some(CHANNEL_KIND_NAME.to_string());
|
||||
let desired = self.desired_channel(&rig_id, bookmark, decoder_kinds);
|
||||
desired_channels.insert(bookmark.id.clone(), desired);
|
||||
}
|
||||
ChannelAction::Skip { reason } => {
|
||||
status.state = reason.to_string();
|
||||
if reason == "handled_by_virtual_channel" {
|
||||
status.channel_kind = Some(VISIBLE_CHANNEL_KIND_NAME.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statuses.push(status);
|
||||
}
|
||||
|
||||
let mut to_remove = Vec::new();
|
||||
for (bookmark_id, channel) in &runtime.active_channels {
|
||||
if let Some(desired) = desired_channels.get(bookmark_id) {
|
||||
if !Self::channel_matches(channel, desired) {
|
||||
to_remove.push(bookmark_id.clone());
|
||||
}
|
||||
} else {
|
||||
to_remove.push(bookmark_id.clone());
|
||||
}
|
||||
}
|
||||
for bookmark_id in to_remove {
|
||||
if let Some(channel) = runtime.active_channels.remove(&bookmark_id) {
|
||||
self.remove_channel(&channel);
|
||||
}
|
||||
}
|
||||
|
||||
for (bookmark_id, desired) in desired_channels {
|
||||
if runtime.active_channels.contains_key(&bookmark_id) {
|
||||
continue;
|
||||
}
|
||||
self.send_audio_cmd_to_rig(
|
||||
&desired.rig_id,
|
||||
VChanAudioCmd::SubscribeBackground {
|
||||
uuid: desired.uuid,
|
||||
freq_hz: desired.freq_hz,
|
||||
mode: desired.mode.clone(),
|
||||
bandwidth_hz: desired.bandwidth_hz,
|
||||
decoder_kinds: desired.decoder_kinds.clone(),
|
||||
},
|
||||
);
|
||||
runtime.active_channels.insert(bookmark_id, desired);
|
||||
}
|
||||
|
||||
let mut guard = self.status.write().await;
|
||||
guard.insert(
|
||||
rig_id.clone(),
|
||||
BackgroundDecodeStatus {
|
||||
rig_id,
|
||||
enabled: config.enabled,
|
||||
active_rig: true,
|
||||
center_hz,
|
||||
sample_rate,
|
||||
entries: statuses,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn scheduler_bookmark_ids(&self, rig_id: &str) -> Vec<String> {
|
||||
let Ok(guard) = self.scheduler_status.read() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(status) = guard.get(rig_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !status.active {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Some(id) = status.last_bookmark_id.clone() {
|
||||
out.push(id);
|
||||
}
|
||||
for id in &status.last_bookmark_ids {
|
||||
if !out.iter().any(|existing| existing == id) {
|
||||
out.push(id.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn run(self: Arc<Self>) {
|
||||
let mut runtime = BackgroundRuntimeState::default();
|
||||
let mut notify_rx = self.notify_tx.subscribe();
|
||||
let mut spectrum_rx: Option<tokio::sync::watch::Receiver<SharedSpectrum>> = None;
|
||||
let mut interval = time::interval(Duration::from_secs(2));
|
||||
|
||||
loop {
|
||||
let users_connected = self.context.sse_clients.load(Ordering::Relaxed) > 0;
|
||||
if users_connected && spectrum_rx.is_none() {
|
||||
spectrum_rx = Some(self.context.spectrum.sender.subscribe());
|
||||
} else if !users_connected {
|
||||
spectrum_rx = None;
|
||||
}
|
||||
|
||||
let spectrum = spectrum_rx
|
||||
.as_ref()
|
||||
.map(|rx| rx.borrow().clone())
|
||||
.unwrap_or_default();
|
||||
self.reconcile(&mut runtime, &spectrum).await;
|
||||
tokio::select! {
|
||||
changed = async {
|
||||
match spectrum_rx.as_mut() {
|
||||
Some(rx) => rx.changed().await.map_err(|_| ()),
|
||||
None => std::future::pending::<Result<(), ()>>().await,
|
||||
}
|
||||
} => {
|
||||
if changed.is_err() {
|
||||
warn!("background decode: spectrum watch closed");
|
||||
self.clear_runtime_channels(&mut runtime);
|
||||
break;
|
||||
}
|
||||
}
|
||||
recv = notify_rx.recv() => {
|
||||
match recv {
|
||||
Ok(()) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ChannelAction {
|
||||
Active,
|
||||
Skip { reason: &'static str },
|
||||
}
|
||||
|
||||
/// Pure decision function that determines whether a bookmark should produce an
|
||||
/// active background-decode channel or be skipped (with a reason).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn evaluate_bookmark(
|
||||
decoder_kinds_empty: bool,
|
||||
enabled: bool,
|
||||
users_connected: bool,
|
||||
scheduler_has_control: bool,
|
||||
scheduled_bookmark_ids: &HashSet<String>,
|
||||
bookmark_id: &str,
|
||||
vchan_covers_bookmark: bool,
|
||||
spectrum_span: Option<(i64, i64)>,
|
||||
freq_hz: u64,
|
||||
) -> ChannelAction {
|
||||
if decoder_kinds_empty {
|
||||
return ChannelAction::Skip {
|
||||
reason: "no_supported_decoders",
|
||||
};
|
||||
}
|
||||
if !enabled {
|
||||
return ChannelAction::Skip { reason: "disabled" };
|
||||
}
|
||||
if !users_connected {
|
||||
return ChannelAction::Skip {
|
||||
reason: "waiting_for_user",
|
||||
};
|
||||
}
|
||||
if scheduler_has_control {
|
||||
return ChannelAction::Skip {
|
||||
reason: "scheduler_has_control",
|
||||
};
|
||||
}
|
||||
if scheduled_bookmark_ids.contains(bookmark_id) {
|
||||
return ChannelAction::Skip {
|
||||
reason: "handled_by_scheduler",
|
||||
};
|
||||
}
|
||||
if vchan_covers_bookmark {
|
||||
return ChannelAction::Skip {
|
||||
reason: "handled_by_virtual_channel",
|
||||
};
|
||||
}
|
||||
let Some((center_hz, half_span_hz)) = spectrum_span else {
|
||||
return ChannelAction::Skip {
|
||||
reason: "waiting_for_spectrum",
|
||||
};
|
||||
};
|
||||
let offset_hz = freq_hz as i64 - center_hz;
|
||||
if offset_hz.abs() > half_span_hz {
|
||||
return ChannelAction::Skip {
|
||||
reason: "out_of_span",
|
||||
};
|
||||
}
|
||||
ChannelAction::Active
|
||||
}
|
||||
|
||||
fn dedup_ids(ids: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for id in ids {
|
||||
if !out.iter().any(|existing| existing == id) {
|
||||
out.push(id.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bookmark_decoder_kinds(bookmark: &Bookmark) -> Vec<String> {
|
||||
resolve_bookmark_decoders(&bookmark.decoders, &bookmark.mode, true)
|
||||
}
|
||||
|
||||
fn channel_matches_bookmark(channel: &ClientChannel, bookmark: &Bookmark) -> bool {
|
||||
channel.freq_hz == bookmark.freq_hz
|
||||
&& normalized_mode(&channel.mode) == normalized_mode(&bookmark.mode)
|
||||
}
|
||||
|
||||
fn normalized_mode(mode: &str) -> String {
|
||||
mode.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[get("/background-decode/{rig_id}")]
|
||||
pub async fn get_background_decode(
|
||||
path: web::Path<String>,
|
||||
manager: web::Data<Arc<BackgroundDecodeManager>>,
|
||||
) -> impl Responder {
|
||||
HttpResponse::Ok().json(manager.get_config(&path.into_inner()).await)
|
||||
}
|
||||
|
||||
#[put("/background-decode/{rig_id}")]
|
||||
pub async fn put_background_decode(
|
||||
path: web::Path<String>,
|
||||
body: web::Json<BackgroundDecodeConfig>,
|
||||
manager: web::Data<Arc<BackgroundDecodeManager>>,
|
||||
) -> impl Responder {
|
||||
let rig_id = path.into_inner();
|
||||
let mut config = body.into_inner();
|
||||
config.rig_id = rig_id;
|
||||
match manager.put_config(config).await {
|
||||
Some(saved) => HttpResponse::Ok().json(saved),
|
||||
None => HttpResponse::InternalServerError().body("failed to save background decode config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/background-decode/{rig_id}")]
|
||||
pub async fn delete_background_decode(
|
||||
path: web::Path<String>,
|
||||
manager: web::Data<Arc<BackgroundDecodeManager>>,
|
||||
) -> impl Responder {
|
||||
let rig_id = path.into_inner();
|
||||
manager.reset_config(&rig_id).await;
|
||||
HttpResponse::Ok().json(BackgroundDecodeConfig {
|
||||
rig_id,
|
||||
enabled: false,
|
||||
bookmark_ids: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/background-decode/{rig_id}/status")]
|
||||
pub async fn get_background_decode_status(
|
||||
path: web::Path<String>,
|
||||
manager: web::Data<Arc<BackgroundDecodeManager>>,
|
||||
) -> impl Responder {
|
||||
HttpResponse::Ok().json(manager.status(&path.into_inner()).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_scheduled() -> HashSet<String> {
|
||||
HashSet::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_when_all_conditions_met() {
|
||||
let action = evaluate_bookmark(
|
||||
false, // decoder_kinds_empty
|
||||
true, // enabled
|
||||
true, // users_connected
|
||||
false, // scheduler_has_control
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false, // vchan_covers_bookmark
|
||||
Some((14_074_000, 96_000)), // spectrum_span (center, half)
|
||||
14_074_000, // freq_hz
|
||||
);
|
||||
assert_eq!(action, ChannelAction::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_no_supported_decoders() {
|
||||
let action = evaluate_bookmark(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "no_supported_decoders"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_disabled() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(action, ChannelAction::Skip { reason: "disabled" });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_waiting_for_user() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "waiting_for_user"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_scheduler_has_control() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "scheduler_has_control"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_handled_by_scheduler() {
|
||||
let mut scheduled = HashSet::new();
|
||||
scheduled.insert("bm1".to_string());
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
&scheduled,
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "handled_by_scheduler"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_handled_by_virtual_channel() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
true,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "handled_by_virtual_channel"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_waiting_for_spectrum() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
None,
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "waiting_for_spectrum"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_out_of_span() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)), // center 14.074 MHz, half span 96 kHz
|
||||
7_074_000, // way outside the span
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "out_of_span"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_at_edge_of_span() {
|
||||
let action = evaluate_bookmark(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000 + 96_000, // exactly at the edge
|
||||
);
|
||||
assert_eq!(action, ChannelAction::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_no_decoders_over_disabled() {
|
||||
// Even if disabled, "no_supported_decoders" should take precedence
|
||||
let action = evaluate_bookmark(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
&empty_scheduled(),
|
||||
"bm1",
|
||||
false,
|
||||
Some((14_074_000, 96_000)),
|
||||
14_074_000,
|
||||
);
|
||||
assert_eq!(
|
||||
action,
|
||||
ChannelAction::Skip {
|
||||
reason: "no_supported_decoders"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Bookmark {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub freq_hz: u64,
|
||||
pub mode: String,
|
||||
pub bandwidth_hz: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub locator: Option<String>,
|
||||
pub comment: String,
|
||||
pub category: String,
|
||||
pub decoders: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct BookmarkStore {
|
||||
db: Arc<RwLock<PickleDb>>,
|
||||
}
|
||||
|
||||
impl BookmarkStore {
|
||||
/// Open (or create) the bookmark store at `path`.
|
||||
pub fn open(path: &Path) -> Self {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let db = if path.exists() {
|
||||
PickleDb::load(
|
||||
path,
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
PickleDb::new(
|
||||
path,
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
PickleDb::new(
|
||||
path,
|
||||
PickleDbDumpPolicy::AutoDump,
|
||||
SerializationMethod::Json,
|
||||
)
|
||||
};
|
||||
Self {
|
||||
db: Arc::new(RwLock::new(db)),
|
||||
}
|
||||
}
|
||||
|
||||
/// General (shared) bookmarks path: `~/.config/trx-rs/bookmarks.db`.
|
||||
pub fn general_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.map(|p| p.join("trx-rs").join("bookmarks.db"))
|
||||
.unwrap_or_else(|| PathBuf::from("bookmarks.db"))
|
||||
}
|
||||
|
||||
/// Per-rig bookmarks path: `~/.config/trx-rs/bookmark.{remote}.db`.
|
||||
pub fn rig_path(remote: &str) -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.map(|p| p.join("trx-rs").join(format!("bookmark.{remote}.db")))
|
||||
.unwrap_or_else(|| PathBuf::from(format!("bookmark.{remote}.db")))
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<Bookmark> {
|
||||
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
|
||||
db.iter()
|
||||
.filter_map(|kv| {
|
||||
if kv.get_key().starts_with("bm:") {
|
||||
kv.get_value::<Bookmark>()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Bookmark> {
|
||||
let db = self.db.read().unwrap_or_else(|e| e.into_inner());
|
||||
db.get::<Bookmark>(&format!("bm:{id}"))
|
||||
}
|
||||
|
||||
/// Insert a new bookmark. Returns false if the DB write fails.
|
||||
pub fn insert(&self, bm: &Bookmark) -> bool {
|
||||
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
|
||||
db.set(&format!("bm:{}", bm.id), bm).is_ok()
|
||||
}
|
||||
|
||||
/// Update an existing bookmark by id. Returns false if not found.
|
||||
pub fn upsert(&self, id: &str, bm: &Bookmark) -> bool {
|
||||
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
|
||||
let key = format!("bm:{id}");
|
||||
if db.exists(&key) {
|
||||
db.set(&key, bm).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a bookmark by id. Returns false if not found.
|
||||
pub fn remove(&self, id: &str) -> bool {
|
||||
let mut db = self.db.write().unwrap_or_else(|e| e.into_inner());
|
||||
db.rem(&format!("bm:{id}")).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`.
|
||||
pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool {
|
||||
self.list()
|
||||
.into_iter()
|
||||
.any(|bm| bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex))
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-tier bookmark storage: a shared **general** store (`bookmarks.db`)
|
||||
/// and lazily-opened per-rig stores (`bookmark.{remote}.db`).
|
||||
pub struct BookmarkStoreMap {
|
||||
general: Arc<BookmarkStore>,
|
||||
rig_stores: Mutex<HashMap<String, Arc<BookmarkStore>>>,
|
||||
}
|
||||
|
||||
impl Default for BookmarkStoreMap {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BookmarkStoreMap {
|
||||
pub fn new() -> Self {
|
||||
let general_path = BookmarkStore::general_path();
|
||||
Self {
|
||||
general: Arc::new(BookmarkStore::open(&general_path)),
|
||||
rig_stores: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared general bookmark store.
|
||||
pub fn general(&self) -> &Arc<BookmarkStore> {
|
||||
&self.general
|
||||
}
|
||||
|
||||
/// Return the per-rig store for `remote`, opening it on first access.
|
||||
pub fn store_for(&self, remote: &str) -> Arc<BookmarkStore> {
|
||||
let mut stores = self.rig_stores.lock().unwrap_or_else(|e| e.into_inner());
|
||||
stores
|
||||
.entry(remote.to_owned())
|
||||
.or_insert_with(|| {
|
||||
let path = BookmarkStore::rig_path(remote);
|
||||
Arc::new(BookmarkStore::open(&path))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Look up a bookmark by id, checking the rig-specific store first,
|
||||
/// then falling back to the general store.
|
||||
pub fn get_for_rig(&self, remote: &str, id: &str) -> Option<Bookmark> {
|
||||
self.store_for(remote)
|
||||
.get(id)
|
||||
.or_else(|| self.general.get(id))
|
||||
}
|
||||
|
||||
/// List all bookmarks visible to `remote`: rig-specific bookmarks merged
|
||||
/// with general bookmarks (rig-specific wins on duplicate IDs).
|
||||
pub fn list_for_rig(&self, remote: &str) -> Vec<Bookmark> {
|
||||
let mut map: HashMap<String, Bookmark> = self
|
||||
.general
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|bm| (bm.id.clone(), bm))
|
||||
.collect();
|
||||
for bm in self.store_for(remote).list() {
|
||||
map.insert(bm.id.clone(), bm);
|
||||
}
|
||||
map.into_values().collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationContext) {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
context.register_frontend("http", server::HttpFrontend::spawn_frontend);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Audio recorder — writes incoming Opus packets to OGG/Opus files.
|
||||
//!
|
||||
//! The recorder subscribes to the same `broadcast::Sender<Bytes>` channels
|
||||
//! that feed the WebSocket audio endpoint, capturing pre-encoded Opus packets
|
||||
//! without any re-encoding.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RecorderConfig {
|
||||
/// Whether the recorder feature is available.
|
||||
pub enabled: bool,
|
||||
/// Directory for recorded files. Default: `$XDG_CACHE_HOME/trx-rs/recordings/`.
|
||||
pub output_dir: Option<String>,
|
||||
/// Maximum duration of a single recording in seconds. None = unlimited.
|
||||
pub max_duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for RecorderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
output_dir: None,
|
||||
max_duration_secs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecorderConfig {
|
||||
pub fn resolve_output_dir(&self) -> PathBuf {
|
||||
if let Some(ref dir) = self.output_dir {
|
||||
PathBuf::from(dir)
|
||||
} else {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".cache"))
|
||||
.join("trx-rs")
|
||||
.join("recordings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Recording metadata
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecordingInfo {
|
||||
pub key: String,
|
||||
pub rig_id: String,
|
||||
pub vchan_id: Option<String>,
|
||||
pub path: String,
|
||||
pub started_at: i64,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecordingResult {
|
||||
pub key: String,
|
||||
pub path: String,
|
||||
pub duration_secs: f64,
|
||||
pub bytes_written: u64,
|
||||
}
|
||||
|
||||
/// Audio stream parameters for a recording.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AudioParams {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u8,
|
||||
pub frame_duration_ms: u16,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OGG/Opus writer
|
||||
// ============================================================================
|
||||
|
||||
/// Minimal OGG/Opus file writer.
|
||||
///
|
||||
/// Writes the mandatory OpusHead and OpusTags pages, then wraps each incoming
|
||||
/// Opus packet in its own OGG page. This produces a valid, seekable OGG Opus
|
||||
/// stream without pulling in an external OGG crate.
|
||||
struct OggOpusWriter {
|
||||
file: std::fs::File,
|
||||
serial: u32,
|
||||
page_seq: u32,
|
||||
granule_pos: u64,
|
||||
samples_per_frame: u64,
|
||||
bytes_written: u64,
|
||||
}
|
||||
|
||||
impl OggOpusWriter {
|
||||
fn create(
|
||||
path: &Path,
|
||||
sample_rate: u32,
|
||||
channels: u8,
|
||||
frame_duration_ms: u16,
|
||||
) -> std::io::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = std::fs::File::create(path)?;
|
||||
|
||||
let serial = {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
(ts & 0xFFFF_FFFF) as u32
|
||||
};
|
||||
|
||||
let samples_per_frame = (sample_rate as u64) * (frame_duration_ms as u64) / 1000;
|
||||
|
||||
let mut writer = Self {
|
||||
file,
|
||||
serial,
|
||||
page_seq: 0,
|
||||
granule_pos: 0,
|
||||
samples_per_frame,
|
||||
bytes_written: 0,
|
||||
};
|
||||
|
||||
writer.write_opus_head(sample_rate, channels)?;
|
||||
writer.write_opus_tags()?;
|
||||
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
/// Write the OpusHead identification header (OGG page, BOS).
|
||||
fn write_opus_head(&mut self, sample_rate: u32, channels: u8) -> std::io::Result<()> {
|
||||
let mut head = Vec::with_capacity(19);
|
||||
head.extend_from_slice(b"OpusHead");
|
||||
head.push(1); // version
|
||||
head.push(channels);
|
||||
head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip
|
||||
head.extend_from_slice(&sample_rate.to_le_bytes()); // input sample rate
|
||||
head.extend_from_slice(&0u16.to_le_bytes()); // output gain
|
||||
head.push(0); // channel mapping family
|
||||
|
||||
// BOS flag = 0x02
|
||||
self.write_ogg_page(0x02, 0, &head)
|
||||
}
|
||||
|
||||
/// Write the OpusTags comment header.
|
||||
fn write_opus_tags(&mut self) -> std::io::Result<()> {
|
||||
let vendor = b"trx-rs";
|
||||
let mut tags = Vec::with_capacity(24);
|
||||
tags.extend_from_slice(b"OpusTags");
|
||||
tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
|
||||
tags.extend_from_slice(vendor);
|
||||
tags.extend_from_slice(&0u32.to_le_bytes()); // no user comments
|
||||
|
||||
self.write_ogg_page(0x00, 0, &tags)
|
||||
}
|
||||
|
||||
/// Write a single Opus audio packet as an OGG page.
|
||||
fn write_audio_packet(&mut self, opus_data: &[u8]) -> std::io::Result<()> {
|
||||
self.granule_pos += self.samples_per_frame;
|
||||
self.write_ogg_page(0x00, self.granule_pos, opus_data)
|
||||
}
|
||||
|
||||
/// Finalize the stream by writing an EOS page.
|
||||
fn finalize(mut self) -> std::io::Result<u64> {
|
||||
// Write an empty EOS page.
|
||||
self.write_ogg_page(0x04, self.granule_pos, &[])?;
|
||||
self.file.flush()?;
|
||||
Ok(self.bytes_written)
|
||||
}
|
||||
|
||||
/// Write a single OGG page.
|
||||
fn write_ogg_page(
|
||||
&mut self,
|
||||
header_type: u8,
|
||||
granule_position: u64,
|
||||
data: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
// OGG page header
|
||||
let mut header = Vec::with_capacity(27 + 255);
|
||||
header.extend_from_slice(b"OggS"); // capture pattern
|
||||
header.push(0); // stream structure version
|
||||
header.push(header_type); // header type flag
|
||||
header.extend_from_slice(&granule_position.to_le_bytes()); // granule position
|
||||
header.extend_from_slice(&self.serial.to_le_bytes()); // stream serial number
|
||||
header.extend_from_slice(&self.page_seq.to_le_bytes()); // page sequence number
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // CRC (placeholder)
|
||||
self.page_seq += 1;
|
||||
|
||||
// Segment table: split data into 255-byte segments.
|
||||
let num_segments = if data.is_empty() {
|
||||
1
|
||||
} else {
|
||||
data.len().div_ceil(255)
|
||||
};
|
||||
// A single packet needs lacing values: full 255-byte segments + final remainder.
|
||||
let mut segments = Vec::with_capacity(num_segments);
|
||||
let mut remaining = data.len();
|
||||
while remaining >= 255 {
|
||||
segments.push(255u8);
|
||||
remaining -= 255;
|
||||
}
|
||||
segments.push(remaining as u8);
|
||||
|
||||
header.push(segments.len() as u8); // number of page segments
|
||||
header.extend_from_slice(&segments);
|
||||
|
||||
// Compute CRC-32 over header + data
|
||||
let crc = ogg_crc32(&header, data);
|
||||
header[22..26].copy_from_slice(&crc.to_le_bytes());
|
||||
|
||||
self.file.write_all(&header)?;
|
||||
self.file.write_all(data)?;
|
||||
self.bytes_written += header.len() as u64 + data.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// OGG CRC-32 (polynomial 0x04C11DB7, direct algorithm).
|
||||
fn ogg_crc32(header: &[u8], data: &[u8]) -> u32 {
|
||||
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
let mut t = [0u32; 256];
|
||||
for i in 0..256u32 {
|
||||
let mut r = i << 24;
|
||||
for _ in 0..8 {
|
||||
r = if r & 0x80000000 != 0 {
|
||||
(r << 1) ^ 0x04C11DB7
|
||||
} else {
|
||||
r << 1
|
||||
};
|
||||
}
|
||||
t[i as usize] = r;
|
||||
}
|
||||
t
|
||||
});
|
||||
|
||||
let mut crc = 0u32;
|
||||
for &b in header.iter().chain(data.iter()) {
|
||||
crc = (crc << 8) ^ table[((crc >> 24) ^ (b as u32)) as usize];
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RecorderHandle
|
||||
// ============================================================================
|
||||
|
||||
struct RecorderHandle {
|
||||
stop_tx: watch::Sender<bool>,
|
||||
handle: tokio::task::JoinHandle<Option<RecordingResult>>,
|
||||
info: RecordingInfo,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RecorderManager
|
||||
// ============================================================================
|
||||
|
||||
pub struct RecorderManager {
|
||||
recordings: Mutex<HashMap<String, RecorderHandle>>,
|
||||
config: RecorderConfig,
|
||||
}
|
||||
|
||||
impl RecorderManager {
|
||||
pub fn new(config: RecorderConfig) -> Self {
|
||||
Self {
|
||||
recordings: Mutex::new(HashMap::new()),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a recording key from rig_id and optional vchan_id.
|
||||
fn make_key(rig_id: &str, vchan_id: Option<&str>) -> String {
|
||||
match vchan_id {
|
||||
Some(v) => format!("{rig_id}:{v}"),
|
||||
None => rig_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start recording the given audio stream.
|
||||
pub fn start(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
vchan_id: Option<&str>,
|
||||
audio_rx: broadcast::Sender<Bytes>,
|
||||
params: AudioParams,
|
||||
freq_hz: Option<u64>,
|
||||
mode: Option<&str>,
|
||||
) -> Result<RecordingInfo, String> {
|
||||
if !self.config.enabled {
|
||||
return Err("recorder is disabled".into());
|
||||
}
|
||||
|
||||
let key = Self::make_key(rig_id, vchan_id);
|
||||
|
||||
let mut recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if recordings.contains_key(&key) {
|
||||
return Err(format!("already recording: {key}"));
|
||||
}
|
||||
|
||||
let output_dir = self.config.resolve_output_dir();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let ts = chrono_timestamp(now.as_secs());
|
||||
|
||||
let filename = {
|
||||
let freq_part = freq_hz.map(|f| format!("_{f}")).unwrap_or_default();
|
||||
let mode_part = mode.map(|m| format!("_{m}")).unwrap_or_default();
|
||||
let vchan_part = vchan_id.map(|v| format!("_vchan-{v}")).unwrap_or_default();
|
||||
format!("{rig_id}{freq_part}{mode_part}{vchan_part}_{ts}.ogg")
|
||||
};
|
||||
let path = output_dir.join(&filename);
|
||||
|
||||
let (stop_tx, stop_rx) = watch::channel(false);
|
||||
let rx = audio_rx.subscribe();
|
||||
let path_clone = path.clone();
|
||||
let max_duration = self.config.max_duration_secs;
|
||||
let key_clone = key.clone();
|
||||
|
||||
let handle = tokio::task::spawn_blocking(move || {
|
||||
run_recorder(&key_clone, &path_clone, rx, stop_rx, params, max_duration)
|
||||
});
|
||||
|
||||
let started_at = now.as_secs() as i64;
|
||||
let info = RecordingInfo {
|
||||
key: key.clone(),
|
||||
rig_id: rig_id.to_string(),
|
||||
vchan_id: vchan_id.map(str::to_string),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
started_at,
|
||||
sample_rate: params.sample_rate,
|
||||
channels: params.channels,
|
||||
};
|
||||
|
||||
recordings.insert(
|
||||
key,
|
||||
RecorderHandle {
|
||||
stop_tx,
|
||||
handle,
|
||||
info: info.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Stop a recording and return the result.
|
||||
pub async fn stop(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
vchan_id: Option<&str>,
|
||||
) -> Result<RecordingResult, String> {
|
||||
let key = Self::make_key(rig_id, vchan_id);
|
||||
let handle = {
|
||||
let mut recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recordings.remove(&key)
|
||||
};
|
||||
match handle {
|
||||
Some(h) => {
|
||||
let _ = h.stop_tx.send(true);
|
||||
match h.handle.await {
|
||||
Ok(Some(result)) => Ok(result),
|
||||
Ok(None) => Err("recording failed".into()),
|
||||
Err(e) => Err(format!("recorder task panicked: {e}")),
|
||||
}
|
||||
}
|
||||
None => Err(format!("no active recording: {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List active recordings.
|
||||
pub fn list_active(&self) -> Vec<RecordingInfo> {
|
||||
let recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recordings.values().map(|h| h.info.clone()).collect()
|
||||
}
|
||||
|
||||
/// List recorded files in the output directory.
|
||||
pub fn list_files(&self) -> Vec<RecordedFile> {
|
||||
let dir = self.config.resolve_output_dir();
|
||||
let mut files = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ogg") {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
files.push(RecordedFile { name, size });
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| b.name.cmp(&a.name)); // newest first
|
||||
files
|
||||
}
|
||||
|
||||
/// Resolve and validate a filename, returning the full path.
|
||||
///
|
||||
/// Rejects path traversal attempts and files outside the output directory.
|
||||
fn validate_filename(&self, filename: &str) -> Result<PathBuf, String> {
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err("invalid filename".into());
|
||||
}
|
||||
if !filename.ends_with(".ogg") {
|
||||
return Err("only .ogg files are accessible".into());
|
||||
}
|
||||
let dir = self.config.resolve_output_dir();
|
||||
let path = dir.join(filename);
|
||||
if !path.exists() {
|
||||
return Err(format!("file not found: {filename}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Get the full path to a recorded file for download.
|
||||
pub fn file_path(&self, filename: &str) -> Result<PathBuf, String> {
|
||||
self.validate_filename(filename)
|
||||
}
|
||||
|
||||
/// Delete a recorded file.
|
||||
pub fn delete_file(&self, filename: &str) -> Result<(), String> {
|
||||
let path = self.validate_filename(filename)?;
|
||||
std::fs::remove_file(&path).map_err(|e| format!("failed to delete: {e}"))
|
||||
}
|
||||
|
||||
/// Check if a recording is active for the given key.
|
||||
pub fn is_recording(&self, rig_id: &str, vchan_id: Option<&str>) -> bool {
|
||||
let key = Self::make_key(rig_id, vchan_id);
|
||||
let recordings = self.recordings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
recordings.contains_key(&key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecordedFile {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Recording task (runs in spawn_blocking)
|
||||
// ============================================================================
|
||||
|
||||
fn run_recorder(
|
||||
key: &str,
|
||||
path: &Path,
|
||||
mut rx: broadcast::Receiver<Bytes>,
|
||||
mut stop_rx: watch::Receiver<bool>,
|
||||
params: AudioParams,
|
||||
max_duration_secs: Option<u64>,
|
||||
) -> Option<RecordingResult> {
|
||||
let mut writer = match OggOpusWriter::create(
|
||||
path,
|
||||
params.sample_rate,
|
||||
params.channels,
|
||||
params.frame_duration_ms,
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Recorder [{key}]: failed to create file {path:?}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Recorder [{key}]: started → {}", path.display());
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let max_dur = max_duration_secs.map(std::time::Duration::from_secs);
|
||||
let mut packets: u64 = 0;
|
||||
|
||||
// Use a small runtime to bridge async broadcast → blocking writer.
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
|
||||
loop {
|
||||
// Check stop signal.
|
||||
if *stop_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check max duration.
|
||||
if let Some(max) = max_dur {
|
||||
if start.elapsed() >= max {
|
||||
info!("Recorder [{key}]: max duration reached");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Receive next Opus packet (blocking in spawn_blocking context).
|
||||
let packet = rt.block_on(async {
|
||||
tokio::select! {
|
||||
result = rx.recv() => Some(result),
|
||||
_ = stop_rx.changed() => None,
|
||||
}
|
||||
});
|
||||
|
||||
match packet {
|
||||
Some(Ok(data)) => {
|
||||
if let Err(e) = writer.write_audio_packet(&data) {
|
||||
error!("Recorder [{key}]: write error: {e}");
|
||||
break;
|
||||
}
|
||||
packets += 1;
|
||||
}
|
||||
Some(Err(broadcast::error::RecvError::Lagged(n))) => {
|
||||
warn!("Recorder [{key}]: dropped {n} packets (lag)");
|
||||
// Continue recording despite lag.
|
||||
}
|
||||
Some(Err(broadcast::error::RecvError::Closed)) => {
|
||||
info!("Recorder [{key}]: audio channel closed");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
// Stop signal received.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration_secs = start.elapsed().as_secs_f64();
|
||||
let bytes_written = match writer.finalize() {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
error!("Recorder [{key}]: finalize error: {e}");
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Recorder [{key}]: stopped — {packets} packets, {duration_secs:.1}s, {} bytes",
|
||||
bytes_written
|
||||
);
|
||||
|
||||
Some(RecordingResult {
|
||||
key: key.to_string(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
duration_secs,
|
||||
bytes_written,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Format a Unix timestamp as `YYYY-MM-DD_HH-MM-SS`.
|
||||
fn chrono_timestamp(epoch_secs: u64) -> String {
|
||||
let secs = epoch_secs;
|
||||
let days = secs / 86400;
|
||||
let time = secs % 86400;
|
||||
let hours = time / 3600;
|
||||
let minutes = (time % 3600) / 60;
|
||||
let seconds = time % 60;
|
||||
|
||||
// Simple Gregorian calendar calculation from epoch days.
|
||||
let (y, m, d) = epoch_days_to_ymd(days as i64);
|
||||
format!("{y:04}-{m:02}-{d:02}_{hours:02}-{minutes:02}-{seconds:02}")
|
||||
}
|
||||
|
||||
fn epoch_days_to_ymd(days: i64) -> (i32, u32, u32) {
|
||||
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
||||
let z = days + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = (z - era * 146097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y as i32, m, d)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#[path = "api/mod.rs"]
|
||||
pub mod api;
|
||||
#[path = "audio.rs"]
|
||||
pub mod audio;
|
||||
#[path = "auth.rs"]
|
||||
pub mod auth;
|
||||
#[path = "background_decode.rs"]
|
||||
pub mod background_decode;
|
||||
#[path = "bookmarks.rs"]
|
||||
pub mod bookmarks;
|
||||
#[path = "recorder.rs"]
|
||||
pub mod recorder;
|
||||
#[path = "scheduler.rs"]
|
||||
pub mod scheduler;
|
||||
#[path = "status.rs"]
|
||||
pub mod status;
|
||||
#[path = "vchan.rs"]
|
||||
pub mod vchan;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_web::dev::Server;
|
||||
use actix_web::{
|
||||
middleware::{Compress, DefaultHeaders, Logger},
|
||||
web, App, HttpServer,
|
||||
};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use trx_core::RigRequest;
|
||||
use trx_core::RigState;
|
||||
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
|
||||
|
||||
use auth::{AuthConfig, AuthState, SameSite};
|
||||
use background_decode::{BackgroundDecodeManager, BackgroundDecodeStore};
|
||||
use recorder::{RecorderConfig, RecorderManager};
|
||||
use scheduler::{
|
||||
SchedulerControlManager, SchedulerStatusMap, SchedulerStoreMap, SharedActivityLogMap,
|
||||
};
|
||||
use vchan::ClientChannelManager;
|
||||
|
||||
/// HTTP frontend implementation.
|
||||
pub struct HttpFrontend;
|
||||
|
||||
impl FrontendSpawner for HttpFrontend {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(listen_addr, state_rx, rig_tx, callsign, context).await {
|
||||
error!("HTTP status server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
addr: SocketAddr,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
callsign: Option<String>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
) -> Result<(), actix_web::Error> {
|
||||
audio::start_decode_history_collector(context.clone());
|
||||
|
||||
// Collect rig IDs for per-rig store initialisation / migration.
|
||||
let rig_ids: Vec<String> = context
|
||||
.routing
|
||||
.remote_rigs
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.map(|r| r.rig_id.clone())
|
||||
.collect();
|
||||
let rig_id_refs: Vec<&str> = rig_ids.iter().map(String::as_str).collect();
|
||||
let scheduler_store = Arc::new(SchedulerStoreMap::new(&rig_id_refs));
|
||||
let bookmark_store_map = Arc::new(bookmarks::BookmarkStoreMap::new());
|
||||
let scheduler_status: SchedulerStatusMap = Arc::new(RwLock::new(HashMap::new()));
|
||||
let scheduler_control = Arc::new(SchedulerControlManager::default());
|
||||
let activity_log_map: SharedActivityLogMap = Arc::new(RwLock::new(HashMap::new()));
|
||||
|
||||
let recorder_config = RecorderConfig::default();
|
||||
let recorder_mgr = Arc::new(RecorderManager::new(recorder_config));
|
||||
|
||||
scheduler::spawn_scheduler_task(
|
||||
context.clone(),
|
||||
rig_tx.clone(),
|
||||
scheduler_store.clone(),
|
||||
bookmark_store_map.clone(),
|
||||
scheduler_status.clone(),
|
||||
scheduler_control.clone(),
|
||||
Some(recorder_mgr.clone()),
|
||||
activity_log_map.clone(),
|
||||
);
|
||||
|
||||
let background_decode_path = BackgroundDecodeStore::default_path();
|
||||
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
|
||||
let vchan_mgr = Arc::new(ClientChannelManager::new(
|
||||
4,
|
||||
context.vchan.rig_audio_cmd.clone(),
|
||||
));
|
||||
let session_rig_mgr = Arc::new(api::SessionRigManager::default());
|
||||
let background_decode_mgr = BackgroundDecodeManager::new(
|
||||
background_decode_store,
|
||||
bookmark_store_map.clone(),
|
||||
context.clone(),
|
||||
scheduler_status.clone(),
|
||||
scheduler_control.clone(),
|
||||
vchan_mgr.clone(),
|
||||
);
|
||||
background_decode_mgr.spawn();
|
||||
|
||||
// Wire the audio-command sender so allocate/delete/freq/mode operations on
|
||||
// virtual channels are forwarded to the audio-client task.
|
||||
if let Ok(guard) = context.vchan.audio_cmd.lock() {
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
vchan_mgr.set_audio_cmd(tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn a task that removes channels destroyed server-side (OOB) from the
|
||||
// client-side registry so the SSE channel list stays in sync.
|
||||
if let Some(ref destroyed_tx) = context.vchan.destroyed {
|
||||
let mut destroyed_rx = destroyed_tx.subscribe();
|
||||
let mgr_for_destroyed = vchan_mgr.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match destroyed_rx.recv().await {
|
||||
Ok(uuid) => {
|
||||
mgr_for_destroyed.remove_by_uuid(uuid);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let server = build_server(
|
||||
addr,
|
||||
state_rx,
|
||||
rig_tx,
|
||||
callsign,
|
||||
context,
|
||||
bookmark_store_map,
|
||||
scheduler_store,
|
||||
scheduler_status,
|
||||
scheduler_control,
|
||||
activity_log_map,
|
||||
vchan_mgr,
|
||||
session_rig_mgr,
|
||||
background_decode_mgr,
|
||||
recorder_mgr,
|
||||
)?;
|
||||
let handle = server.handle();
|
||||
tokio::spawn(async move {
|
||||
let _ = signal::ctrl_c().await;
|
||||
handle.stop(false).await;
|
||||
});
|
||||
info!("http frontend listening on {}", addr);
|
||||
info!("http frontend ready (status/control)");
|
||||
server.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_server(
|
||||
addr: SocketAddr,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
context: Arc<FrontendRuntimeContext>,
|
||||
bookmark_store_map: Arc<bookmarks::BookmarkStoreMap>,
|
||||
scheduler_store: Arc<SchedulerStoreMap>,
|
||||
scheduler_status: SchedulerStatusMap,
|
||||
scheduler_control: Arc<SchedulerControlManager>,
|
||||
activity_log_map: SharedActivityLogMap,
|
||||
vchan_mgr: Arc<ClientChannelManager>,
|
||||
session_rig_mgr: Arc<api::SessionRigManager>,
|
||||
background_decode_mgr: Arc<BackgroundDecodeManager>,
|
||||
recorder_mgr: Arc<RecorderManager>,
|
||||
) -> Result<Server, actix_web::Error> {
|
||||
let state_data = web::Data::new(state_rx);
|
||||
let rig_tx = web::Data::new(rig_tx);
|
||||
// Share the same AtomicUsize that lives in FrontendRuntimeContext so the
|
||||
// scheduler task can observe the connected-client count.
|
||||
let clients = web::Data::new(context.sse_clients.clone());
|
||||
|
||||
let bookmark_store = web::Data::new(bookmark_store_map);
|
||||
|
||||
let scheduler_store = web::Data::new(scheduler_store);
|
||||
let scheduler_status = web::Data::new(scheduler_status);
|
||||
let scheduler_control = web::Data::new(scheduler_control);
|
||||
let activity_log_map = web::Data::new(activity_log_map);
|
||||
let vchan_mgr = web::Data::new(vchan_mgr);
|
||||
let session_rig_mgr = web::Data::new(session_rig_mgr);
|
||||
let background_decode_mgr = web::Data::new(background_decode_mgr);
|
||||
let recorder_mgr = web::Data::new(recorder_mgr);
|
||||
|
||||
// Extract auth config values before moving context
|
||||
let same_site = match context.http_auth.cookie_same_site.as_str() {
|
||||
"Strict" => SameSite::Strict,
|
||||
"None" => SameSite::None,
|
||||
_ => SameSite::Lax, // default
|
||||
};
|
||||
let auth_config = AuthConfig::new(
|
||||
context.http_auth.enabled,
|
||||
context.http_auth.rx_passphrase.clone(),
|
||||
context.http_auth.control_passphrase.clone(),
|
||||
context.http_auth.tx_access_control_enabled,
|
||||
Duration::from_secs(context.http_auth.session_ttl_secs),
|
||||
context.http_auth.cookie_secure,
|
||||
same_site,
|
||||
);
|
||||
|
||||
// Warn operators if auth is enabled but cookie_secure is false,
|
||||
// which means session cookies will be sent over plain HTTP.
|
||||
if auth_config.enabled && !auth_config.cookie_secure {
|
||||
warn!(
|
||||
"HTTP auth is enabled but cookie_secure is false — \
|
||||
session cookies will be sent over unencrypted connections. \
|
||||
Set cookie_secure = true when behind a TLS-terminating proxy."
|
||||
);
|
||||
}
|
||||
|
||||
let context_data = web::Data::new(context);
|
||||
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
|
||||
|
||||
// Spawn session cleanup task if auth is enabled
|
||||
if auth_config.enabled {
|
||||
let store_cleanup = auth_state.store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300)); // 5 minutes
|
||||
loop {
|
||||
interval.tick().await;
|
||||
store_cleanup.cleanup_expired();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(state_data.clone())
|
||||
.app_data(rig_tx.clone())
|
||||
.app_data(clients.clone())
|
||||
.app_data(context_data.clone())
|
||||
.app_data(auth_state.clone())
|
||||
.app_data(bookmark_store.clone())
|
||||
.app_data(scheduler_store.clone())
|
||||
.app_data(scheduler_status.clone())
|
||||
.app_data(scheduler_control.clone())
|
||||
.app_data(activity_log_map.clone())
|
||||
.app_data(vchan_mgr.clone())
|
||||
.app_data(session_rig_mgr.clone())
|
||||
.app_data(background_decode_mgr.clone())
|
||||
.app_data(recorder_mgr.clone())
|
||||
.wrap(Compress::default())
|
||||
.wrap(
|
||||
DefaultHeaders::new()
|
||||
.add(("Referrer-Policy", "same-origin"))
|
||||
.add(("Cross-Origin-Resource-Policy", "same-origin"))
|
||||
.add(("Cross-Origin-Opener-Policy", "same-origin"))
|
||||
.add(("X-Content-Type-Options", "nosniff")),
|
||||
)
|
||||
// Use "real IP" so reverse-proxy setups can pass client address
|
||||
// via Forwarded / X-Forwarded-For / X-Real-IP headers.
|
||||
.wrap(Logger::new(
|
||||
r#"%{r}a "%r" %s %b "%{Referer}i" "%{User-Agent}i" %T"#,
|
||||
))
|
||||
.wrap(auth::AuthMiddleware)
|
||||
.configure(api::configure)
|
||||
})
|
||||
.shutdown_timeout(1)
|
||||
.disable_signals()
|
||||
.bind(addr)?
|
||||
.run();
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.configure(api::configure);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
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 THEMES_CSS: &str = include_str!("../assets/web/themes.css");
|
||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
|
||||
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.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");
|
||||
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
||||
pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
|
||||
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
||||
pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js");
|
||||
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||
pub const FT4_JS: &str = include_str!("../assets/web/plugins/ft4.js");
|
||||
pub const FT2_JS: &str = include_str!("../assets/web/plugins/ft2.js");
|
||||
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 SAT_JS: &str = include_str!("../assets/web/plugins/sat.js");
|
||||
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.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 SAT_SCHEDULER_JS: &str = include_str!("../assets/web/plugins/sat-scheduler.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 const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
|
||||
|
||||
// Vendored DSEG14 Classic font
|
||||
pub const DSEG14_CLASSIC_WOFF2: &[u8] =
|
||||
include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2");
|
||||
|
||||
// Vendored Leaflet 1.9.4
|
||||
pub const LEAFLET_JS: &str = include_str!("../assets/web/vendor/leaflet.js");
|
||||
pub const LEAFLET_CSS: &str = include_str!("../assets/web/vendor/leaflet.css");
|
||||
pub const LEAFLET_MARKER_ICON: &[u8] = include_bytes!("../assets/web/vendor/marker-icon.png");
|
||||
pub const LEAFLET_MARKER_ICON_2X: &[u8] = include_bytes!("../assets/web/vendor/marker-icon-2x.png");
|
||||
pub const LEAFLET_MARKER_SHADOW: &[u8] = include_bytes!("../assets/web/vendor/marker-shadow.png");
|
||||
pub const LEAFLET_LAYERS: &[u8] = include_bytes!("../assets/web/vendor/layers.png");
|
||||
pub const LEAFLET_LAYERS_2X: &[u8] = include_bytes!("../assets/web/vendor/layers-2x.png");
|
||||
|
||||
/// Build version tag used for cache-busting asset URLs and ETag headers.
|
||||
/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`.
|
||||
pub fn build_version_tag() -> &'static str {
|
||||
static TAG: OnceLock<String> = OnceLock::new();
|
||||
TAG.get_or_init(|| format!("{PKG_VERSION}-{CLIENT_BUILD_DATE}"))
|
||||
}
|
||||
|
||||
/// Pre-computed index HTML with version/date placeholders resolved.
|
||||
/// Computed once on first access, avoiding three `.replace()` calls per
|
||||
/// request on the ~50 KB HTML template.
|
||||
pub fn index_html() -> &'static str {
|
||||
static HTML: OnceLock<String> = OnceLock::new();
|
||||
HTML.get_or_init(|| {
|
||||
INDEX_HTML
|
||||
.replace("{pkg}", PKG_NAME)
|
||||
.replace("{ver}", PKG_VERSION)
|
||||
.replace("{client_build_date}", CLIENT_BUILD_DATE)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Client-side virtual channel registry.
|
||||
//!
|
||||
//! Each rig has a list of virtual channels tracked entirely within the HTTP
|
||||
//! frontend process. Channel 0 is permanent and mirrors the rig's current
|
||||
//! dial frequency. Additional channels are allocated by a tab (identified by
|
||||
//! its SSE session UUID) and freed when that session disconnects or the tab
|
||||
//! explicitly deletes them.
|
||||
//!
|
||||
//! Actual DSP on the server is unaffected by this registry in Phase 1; the
|
||||
//! registry is the source of truth for metadata (freq/mode per channel) and
|
||||
//! drives `SetFreq`/`SetMode` commands to the server when a tab selects or
|
||||
//! tunes a channel.
|
||||
//!
|
||||
//! # Lock ordering
|
||||
//!
|
||||
//! [`ClientChannelManager`] owns several synchronisation primitives. To
|
||||
//! prevent deadlocks, all code in this module acquires them in the following
|
||||
//! fixed order:
|
||||
//!
|
||||
//! 1. `rigs` (RwLock) — primary channel data
|
||||
//! 2. `sessions` (RwLock) — session-to-channel mapping
|
||||
//! 3. `audio_cmd` / `rig_vchan_audio_cmd` (Mutex / RwLock) — fire-and-forget command senders
|
||||
//!
|
||||
//! **Rule**: always `drop(rigs)` before acquiring `sessions` or `audio_cmd`.
|
||||
//! The command senders are independent of the first two and may be acquired
|
||||
//! at any point provided no higher-priority lock is held.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use trx_frontend::VChanAudioCmd;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HTTP-visible snapshot of one channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientChannel {
|
||||
pub id: Uuid,
|
||||
/// Position in the ordered list (0 = primary).
|
||||
pub index: usize,
|
||||
pub freq_hz: u64,
|
||||
pub mode: String,
|
||||
/// Audio filter bandwidth in Hz (0 = mode default).
|
||||
pub bandwidth_hz: u32,
|
||||
/// True for channel 0 — cannot be deleted.
|
||||
pub permanent: bool,
|
||||
/// Number of SSE sessions currently subscribed to this channel.
|
||||
pub subscribers: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SelectedChannel {
|
||||
pub id: Uuid,
|
||||
pub freq_hz: u64,
|
||||
pub mode: String,
|
||||
pub bandwidth_hz: u32,
|
||||
pub scheduler_bookmark_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VChanClientError {
|
||||
/// Channel cap would be exceeded.
|
||||
CapReached { max: usize },
|
||||
/// Channel UUID not found.
|
||||
NotFound,
|
||||
/// Tried to delete the permanent primary channel.
|
||||
Permanent,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VChanClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VChanClientError::CapReached { max } => {
|
||||
write!(f, "channel cap reached (max {})", max)
|
||||
}
|
||||
VChanClientError::NotFound => write!(f, "channel not found"),
|
||||
VChanClientError::Permanent => write!(f, "cannot remove the primary channel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal record
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct InternalChannel {
|
||||
id: Uuid,
|
||||
freq_hz: u64,
|
||||
mode: String,
|
||||
/// Audio filter bandwidth in Hz (0 = mode default).
|
||||
bandwidth_hz: u32,
|
||||
decoder_kinds: Vec<String>,
|
||||
permanent: bool,
|
||||
scheduler_bookmark_id: Option<String>,
|
||||
/// Session UUIDs currently subscribed to this channel.
|
||||
session_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClientChannelManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-rig channel registry shared across all actix handlers.
|
||||
pub struct ClientChannelManager {
|
||||
/// rig_id → ordered channel list.
|
||||
rigs: RwLock<HashMap<String, Vec<InternalChannel>>>,
|
||||
/// session_id → (rig_id, channel_id).
|
||||
sessions: RwLock<HashMap<Uuid, (String, Uuid)>>,
|
||||
/// Broadcast used to push updated channel lists to SSE streams.
|
||||
/// Payload: JSON string (serialised `Vec<ClientChannel>`), prefixed by
|
||||
/// `"<rig_id>:"` so subscribers can filter by rig.
|
||||
pub change_tx: broadcast::Sender<String>,
|
||||
pub max_channels: usize,
|
||||
/// Global fallback sender to the audio-client task for virtual-channel audio commands.
|
||||
pub audio_cmd: std::sync::Mutex<Option<mpsc::Sender<VChanAudioCmd>>>,
|
||||
/// Per-rig vchan command senders. Commands are routed to the per-rig sender
|
||||
/// when available, falling back to the global `audio_cmd`.
|
||||
pub rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
|
||||
}
|
||||
|
||||
impl ClientChannelManager {
|
||||
pub fn new(
|
||||
max_channels: usize,
|
||||
rig_vchan_audio_cmd: Arc<RwLock<HashMap<String, mpsc::Sender<VChanAudioCmd>>>>,
|
||||
) -> Self {
|
||||
let (change_tx, _) = broadcast::channel(64);
|
||||
Self {
|
||||
rigs: RwLock::new(HashMap::new()),
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
change_tx,
|
||||
max_channels: max_channels.max(1),
|
||||
audio_cmd: std::sync::Mutex::new(None),
|
||||
rig_vchan_audio_cmd,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the global audio-command sender as fallback.
|
||||
pub fn set_audio_cmd(&self, tx: mpsc::Sender<VChanAudioCmd>) {
|
||||
*self.audio_cmd.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||
}
|
||||
|
||||
/// Fire-and-forget: send a `VChanAudioCmd`, routing to the per-rig sender
|
||||
/// when available or falling back to the global sender.
|
||||
fn send_audio_cmd_for_rig(&self, rig_id: &str, cmd: VChanAudioCmd) {
|
||||
// Try per-rig sender first.
|
||||
if let Ok(map) = self.rig_vchan_audio_cmd.read() {
|
||||
if let Some(tx) = map.get(rig_id) {
|
||||
let _ = tx.try_send(cmd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fall back to global sender.
|
||||
if let Some(tx) = self
|
||||
.audio_cmd
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
{
|
||||
let _ = tx.try_send(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------
|
||||
|
||||
fn broadcast_change(&self, rig_id: &str, channels: &[InternalChannel]) {
|
||||
let list: Vec<ClientChannel> = channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, c)| ClientChannel {
|
||||
id: c.id,
|
||||
index: idx,
|
||||
freq_hz: c.freq_hz,
|
||||
mode: c.mode.clone(),
|
||||
bandwidth_hz: c.bandwidth_hz,
|
||||
permanent: c.permanent || c.scheduler_bookmark_id.is_some(),
|
||||
subscribers: c.session_ids.len(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&list) {
|
||||
let _ = self.change_tx.send(format!("{}:{}", rig_id, json));
|
||||
}
|
||||
}
|
||||
|
||||
// -- public API -------------------------------------------------------
|
||||
|
||||
/// Ensure channel 0 exists for `rig_id`. Call this when the SSE stream
|
||||
/// first delivers rig state so the primary channel reflects the current freq.
|
||||
pub fn init_rig(&self, rig_id: &str, freq_hz: u64, mode: &str) {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.entry(rig_id.to_string()).or_default();
|
||||
if channels.is_empty() {
|
||||
channels.push(InternalChannel {
|
||||
id: Uuid::new_v4(),
|
||||
freq_hz,
|
||||
mode: mode.to_string(),
|
||||
bandwidth_hz: 0,
|
||||
decoder_kinds: Vec::new(),
|
||||
permanent: true,
|
||||
scheduler_bookmark_id: None,
|
||||
session_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update channel 0's freq/mode when the server pushes a new rig state.
|
||||
pub fn update_primary(&self, rig_id: &str, freq_hz: u64, mode: &str) {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(channels) = rigs.get_mut(rig_id) {
|
||||
if let Some(ch) = channels.first_mut() {
|
||||
if ch.freq_hz != freq_hz || ch.mode != mode {
|
||||
ch.freq_hz = freq_hz;
|
||||
ch.mode = mode.to_string();
|
||||
self.broadcast_change(rig_id, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all channels for a rig (returns empty vec if rig unknown).
|
||||
pub fn channels(&self, rig_id: &str) -> Vec<ClientChannel> {
|
||||
let rigs = self.rigs.read().unwrap_or_else(|e| e.into_inner());
|
||||
rigs.get(rig_id)
|
||||
.map(|chs| {
|
||||
chs.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, c)| ClientChannel {
|
||||
id: c.id,
|
||||
index: idx,
|
||||
freq_hz: c.freq_hz,
|
||||
mode: c.mode.clone(),
|
||||
bandwidth_hz: c.bandwidth_hz,
|
||||
permanent: c.permanent || c.scheduler_bookmark_id.is_some(),
|
||||
subscribers: c.session_ids.len(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Allocate a new virtual channel for `session_id`.
|
||||
/// If `session_id` already owns a channel on this rig, it is released first.
|
||||
/// Returns the new `ClientChannel` snapshot.
|
||||
pub fn allocate(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
rig_id: &str,
|
||||
freq_hz: u64,
|
||||
mode: &str,
|
||||
) -> Result<ClientChannel, VChanClientError> {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.entry(rig_id.to_string()).or_default();
|
||||
|
||||
if channels.len() >= self.max_channels {
|
||||
return Err(VChanClientError::CapReached {
|
||||
max: self.max_channels,
|
||||
});
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let idx = channels.len();
|
||||
channels.push(InternalChannel {
|
||||
id,
|
||||
freq_hz,
|
||||
mode: mode.to_string(),
|
||||
bandwidth_hz: 0,
|
||||
decoder_kinds: Vec::new(),
|
||||
permanent: false,
|
||||
scheduler_bookmark_id: None,
|
||||
session_ids: vec![session_id],
|
||||
});
|
||||
|
||||
let snapshot = ClientChannel {
|
||||
id,
|
||||
index: idx,
|
||||
freq_hz,
|
||||
mode: mode.to_string(),
|
||||
bandwidth_hz: 0,
|
||||
permanent: false,
|
||||
subscribers: 1,
|
||||
};
|
||||
|
||||
self.broadcast_change(rig_id, channels);
|
||||
|
||||
// Update session → channel mapping.
|
||||
drop(rigs);
|
||||
self.sessions
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(session_id, (rig_id.to_string(), id));
|
||||
|
||||
// Request server-side DSP channel + audio subscription.
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::Subscribe {
|
||||
uuid: id,
|
||||
freq_hz,
|
||||
mode: mode.to_string(),
|
||||
bandwidth_hz: 0,
|
||||
decoder_kinds: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Subscribe an SSE session to a channel (by channel UUID).
|
||||
/// Idempotent. Returns `None` if channel not found.
|
||||
pub fn subscribe_session(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
rig_id: &str,
|
||||
channel_id: Uuid,
|
||||
) -> Option<ClientChannel> {
|
||||
// Release previous subscription on this rig.
|
||||
self.release_session_on_rig(session_id, rig_id);
|
||||
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.get_mut(rig_id)?;
|
||||
let (idx, ch) = channels
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.find(|(_, c)| c.id == channel_id)?;
|
||||
|
||||
if !ch.session_ids.contains(&session_id) {
|
||||
ch.session_ids.push(session_id);
|
||||
}
|
||||
let snapshot = ClientChannel {
|
||||
id: ch.id,
|
||||
index: idx,
|
||||
freq_hz: ch.freq_hz,
|
||||
mode: ch.mode.clone(),
|
||||
bandwidth_hz: ch.bandwidth_hz,
|
||||
permanent: ch.permanent || ch.scheduler_bookmark_id.is_some(),
|
||||
subscribers: ch.session_ids.len(),
|
||||
};
|
||||
|
||||
self.broadcast_change(rig_id, channels);
|
||||
|
||||
drop(rigs);
|
||||
self.sessions
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(session_id, (rig_id.to_string(), channel_id));
|
||||
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
/// Release all channel subscriptions for `session_id` across all rigs.
|
||||
/// Auto-removes non-permanent channels that reach 0 subscribers.
|
||||
pub fn release_session(&self, session_id: Uuid) {
|
||||
let mapping = {
|
||||
let mut sessions = self.sessions.write().unwrap_or_else(|e| e.into_inner());
|
||||
sessions.remove(&session_id)
|
||||
};
|
||||
if let Some((rig_id, _)) = mapping {
|
||||
self.release_session_on_rig(session_id, &rig_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn release_session_on_rig(&self, session_id: Uuid, rig_id: &str) {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(channels) = rigs.get_mut(rig_id) else {
|
||||
return;
|
||||
};
|
||||
let mut changed = false;
|
||||
let mut removed_channel_ids = Vec::new();
|
||||
for ch in channels.iter_mut() {
|
||||
if let Some(pos) = ch.session_ids.iter().position(|&s| s == session_id) {
|
||||
ch.session_ids.remove(pos);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let mut idx = 0;
|
||||
while idx < channels.len() {
|
||||
if !channels[idx].permanent
|
||||
&& channels[idx].scheduler_bookmark_id.is_none()
|
||||
&& channels[idx].session_ids.is_empty()
|
||||
{
|
||||
removed_channel_ids.push(channels[idx].id);
|
||||
channels.remove(idx);
|
||||
changed = true;
|
||||
} else {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.broadcast_change(rig_id, channels);
|
||||
}
|
||||
drop(rigs);
|
||||
|
||||
for channel_id in removed_channel_ids {
|
||||
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly delete a channel by UUID (any session may do this).
|
||||
pub fn delete_channel(&self, rig_id: &str, channel_id: Uuid) -> Result<(), VChanClientError> {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
|
||||
let pos = channels
|
||||
.iter()
|
||||
.position(|c| c.id == channel_id)
|
||||
.ok_or(VChanClientError::NotFound)?;
|
||||
if channels[pos].permanent || channels[pos].scheduler_bookmark_id.is_some() {
|
||||
return Err(VChanClientError::Permanent);
|
||||
}
|
||||
// Collect evicted sessions to clean up the session map.
|
||||
let evicted: Vec<Uuid> = channels[pos].session_ids.clone();
|
||||
channels.remove(pos);
|
||||
self.broadcast_change(rig_id, channels);
|
||||
drop(rigs);
|
||||
|
||||
let mut sessions = self.sessions.write().unwrap_or_else(|e| e.into_inner());
|
||||
for sid in evicted {
|
||||
sessions.remove(&sid);
|
||||
}
|
||||
|
||||
// Remove server-side DSP channel and stop audio encoding.
|
||||
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a channel by UUID across all rigs (called when the server destroys
|
||||
/// it due to out-of-band center-frequency change). Does NOT send a
|
||||
/// `VChanAudioCmd::Remove` since the server-side channel is already gone.
|
||||
pub fn remove_by_uuid(&self, channel_id: Uuid) {
|
||||
let evicted_sessions: Vec<Uuid>;
|
||||
let rig_id_opt: Option<String>;
|
||||
{
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut found = false;
|
||||
let mut evicted = Vec::new();
|
||||
let mut found_rig = None;
|
||||
for (rig_id, channels) in rigs.iter_mut() {
|
||||
if let Some(pos) = channels.iter().position(|c| c.id == channel_id) {
|
||||
evicted = channels[pos].session_ids.clone();
|
||||
channels.remove(pos);
|
||||
self.broadcast_change(rig_id, channels);
|
||||
found_rig = Some(rig_id.clone());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
evicted_sessions = evicted;
|
||||
rig_id_opt = found_rig;
|
||||
let _ = found; // suppress warning
|
||||
}
|
||||
// Clean up session → channel mapping for sessions that were subscribed.
|
||||
if rig_id_opt.is_some() {
|
||||
let mut sessions = self.sessions.write().unwrap_or_else(|e| e.into_inner());
|
||||
for sid in evicted_sessions {
|
||||
if matches!(sessions.get(&sid), Some((_, ch)) if *ch == channel_id) {
|
||||
sessions.remove(&sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update freq/mode metadata for a channel.
|
||||
pub fn set_channel_freq(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
channel_id: Uuid,
|
||||
freq_hz: u64,
|
||||
) -> Result<(), VChanClientError> {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
|
||||
let ch = channels
|
||||
.iter_mut()
|
||||
.find(|c| c.id == channel_id)
|
||||
.ok_or(VChanClientError::NotFound)?;
|
||||
ch.freq_hz = freq_hz;
|
||||
self.broadcast_change(rig_id, channels);
|
||||
drop(rigs);
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::SetFreq {
|
||||
uuid: channel_id,
|
||||
freq_hz,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_channel_mode(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
channel_id: Uuid,
|
||||
mode: &str,
|
||||
) -> Result<(), VChanClientError> {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
|
||||
let ch = channels
|
||||
.iter_mut()
|
||||
.find(|c| c.id == channel_id)
|
||||
.ok_or(VChanClientError::NotFound)?;
|
||||
ch.mode = mode.to_string();
|
||||
self.broadcast_change(rig_id, channels);
|
||||
drop(rigs);
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::SetMode {
|
||||
uuid: channel_id,
|
||||
mode: mode.to_string(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_channel_bandwidth(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
channel_id: Uuid,
|
||||
bandwidth_hz: u32,
|
||||
) -> Result<(), VChanClientError> {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.get_mut(rig_id).ok_or(VChanClientError::NotFound)?;
|
||||
let ch = channels
|
||||
.iter_mut()
|
||||
.find(|c| c.id == channel_id)
|
||||
.ok_or(VChanClientError::NotFound)?;
|
||||
ch.bandwidth_hz = bandwidth_hz;
|
||||
self.broadcast_change(rig_id, channels);
|
||||
drop(rigs);
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::SetBandwidth {
|
||||
uuid: channel_id,
|
||||
bandwidth_hz,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the channel a session is currently subscribed to.
|
||||
pub fn session_channel(&self, session_id: Uuid) -> Option<(String, Uuid)> {
|
||||
self.sessions
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Return the selected channel's tune metadata.
|
||||
pub fn selected_channel(&self, rig_id: &str, channel_id: Uuid) -> Option<SelectedChannel> {
|
||||
let rigs = self.rigs.read().unwrap_or_else(|e| e.into_inner());
|
||||
let channels = rigs.get(rig_id)?;
|
||||
let channel = channels.iter().find(|channel| channel.id == channel_id)?;
|
||||
Some(SelectedChannel {
|
||||
id: channel.id,
|
||||
freq_hz: channel.freq_hz,
|
||||
mode: channel.mode.clone(),
|
||||
bandwidth_hz: channel.bandwidth_hz,
|
||||
scheduler_bookmark_id: channel.scheduler_bookmark_id.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconcile visible scheduler-managed channels for a rig.
|
||||
///
|
||||
/// These channels are user-visible virtual channels sourced from the
|
||||
/// scheduler's currently active extra bookmarks. They are kept separate
|
||||
/// from user-allocated channels so connect-time sync can materialise them
|
||||
/// without duplicating arbitrary user state.
|
||||
pub fn sync_scheduler_channels(
|
||||
&self,
|
||||
rig_id: &str,
|
||||
desired: &[(String, u64, String, u32, Vec<String>)],
|
||||
) {
|
||||
let mut rigs = self.rigs.write().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(channels) = rigs.get_mut(rig_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
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)| {
|
||||
(
|
||||
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();
|
||||
|
||||
let mut idx = 0;
|
||||
while idx < channels.len() {
|
||||
let remove = if let Some(bookmark_id) = channels[idx].scheduler_bookmark_id.as_deref() {
|
||||
!desired_ids.contains(bookmark_id) && channels[idx].session_ids.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if remove {
|
||||
let channel_id = channels[idx].id;
|
||||
channels.remove(idx);
|
||||
self.send_audio_cmd_for_rig(rig_id, VChanAudioCmd::Remove(channel_id));
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
for channel in channels.iter_mut() {
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
if channel.freq_hz != *freq_hz {
|
||||
channel.freq_hz = *freq_hz;
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::SetFreq {
|
||||
uuid: channel.id,
|
||||
freq_hz: *freq_hz,
|
||||
},
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if channel.mode != *mode {
|
||||
channel.mode = mode.clone();
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::SetMode {
|
||||
uuid: channel.id,
|
||||
mode: mode.clone(),
|
||||
},
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if channel.bandwidth_hz != *bandwidth_hz {
|
||||
channel.bandwidth_hz = *bandwidth_hz;
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::SetBandwidth {
|
||||
uuid: channel.id,
|
||||
bandwidth_hz: *bandwidth_hz,
|
||||
},
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if channel.decoder_kinds != *decoder_kinds {
|
||||
channel.decoder_kinds = decoder_kinds.clone();
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::Subscribe {
|
||||
uuid: channel.id,
|
||||
freq_hz: channel.freq_hz,
|
||||
mode: channel.mode.clone(),
|
||||
bandwidth_hz: channel.bandwidth_hz,
|
||||
decoder_kinds: channel.decoder_kinds.clone(),
|
||||
},
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (bookmark_id, freq_hz, mode, bandwidth_hz, decoder_kinds) in desired {
|
||||
let exists = channels.iter().any(|channel| {
|
||||
channel.scheduler_bookmark_id.as_deref() == Some(bookmark_id.as_str())
|
||||
});
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
if channels.len() >= self.max_channels {
|
||||
break;
|
||||
}
|
||||
let channel_id = Uuid::new_v4();
|
||||
channels.push(InternalChannel {
|
||||
id: channel_id,
|
||||
freq_hz: *freq_hz,
|
||||
mode: mode.clone(),
|
||||
bandwidth_hz: *bandwidth_hz,
|
||||
decoder_kinds: decoder_kinds.clone(),
|
||||
permanent: false,
|
||||
scheduler_bookmark_id: Some(bookmark_id.clone()),
|
||||
session_ids: Vec::new(),
|
||||
});
|
||||
self.send_audio_cmd_for_rig(
|
||||
rig_id,
|
||||
VChanAudioCmd::Subscribe {
|
||||
uuid: channel_id,
|
||||
freq_hz: *freq_hz,
|
||||
mode: mode.clone(),
|
||||
bandwidth_hz: *bandwidth_hz,
|
||||
decoder_kinds: decoder_kinds.clone(),
|
||||
},
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.broadcast_change(rig_id, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_session_removes_last_non_permanent_channel() {
|
||||
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||
let rig_id = "rig-a";
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||
let channel = mgr
|
||||
.allocate(session_id, rig_id, 14_075_000, "DIG")
|
||||
.expect("allocate vchan");
|
||||
|
||||
assert_eq!(mgr.channels(rig_id).len(), 2);
|
||||
|
||||
mgr.release_session(session_id);
|
||||
|
||||
let channels = mgr.channels(rig_id);
|
||||
assert_eq!(channels.len(), 1);
|
||||
assert!(channels.iter().all(|ch| ch.id != channel.id));
|
||||
assert!(mgr.session_channel(session_id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_scheduler_channels_materializes_visible_scheduler_channels() {
|
||||
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||
let rig_id = "rig-a";
|
||||
|
||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||
mgr.sync_scheduler_channels(
|
||||
rig_id,
|
||||
&[(
|
||||
"bm-ft8".to_string(),
|
||||
14_074_000,
|
||||
"DIG".to_string(),
|
||||
3_000,
|
||||
vec!["ft8".to_string()],
|
||||
)],
|
||||
);
|
||||
|
||||
let channels = mgr.channels(rig_id);
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert_eq!(channels[1].freq_hz, 14_074_000);
|
||||
assert_eq!(channels[1].mode, "DIG");
|
||||
assert_eq!(channels[1].bandwidth_hz, 3_000);
|
||||
assert_eq!(channels[1].subscribers, 0);
|
||||
assert!(channels[1].permanent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_session_keeps_scheduler_managed_channels() {
|
||||
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||
let rig_id = "rig-a";
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||
let _channel = mgr
|
||||
.allocate(session_id, rig_id, 14_075_000, "DIG")
|
||||
.expect("allocate vchan");
|
||||
mgr.sync_scheduler_channels(
|
||||
rig_id,
|
||||
&[(
|
||||
"bm-ft8".to_string(),
|
||||
14_074_000,
|
||||
"DIG".to_string(),
|
||||
3_000,
|
||||
vec!["ft8".to_string()],
|
||||
)],
|
||||
);
|
||||
|
||||
mgr.release_session(session_id);
|
||||
|
||||
let channels = mgr.channels(rig_id);
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert_eq!(channels[1].mode, "DIG");
|
||||
assert_eq!(channels[1].subscribers, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribed_scheduler_channel_survives_scheduler_clear_until_released() {
|
||||
let mgr = ClientChannelManager::new(4, Arc::new(RwLock::new(HashMap::new())));
|
||||
let rig_id = "rig-a";
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
mgr.init_rig(rig_id, 14_074_000, "USB");
|
||||
mgr.sync_scheduler_channels(
|
||||
rig_id,
|
||||
&[(
|
||||
"bm-aprs".to_string(),
|
||||
144_800_000,
|
||||
"PKT".to_string(),
|
||||
12_500,
|
||||
vec!["aprs".to_string()],
|
||||
)],
|
||||
);
|
||||
|
||||
let channel_id = mgr.channels(rig_id)[1].id;
|
||||
mgr.subscribe_session(session_id, rig_id, channel_id)
|
||||
.expect("subscribe scheduler channel");
|
||||
|
||||
mgr.sync_scheduler_channels(rig_id, &[]);
|
||||
|
||||
let channels = mgr.channels(rig_id);
|
||||
assert_eq!(channels.len(), 2);
|
||||
assert_eq!(channels[1].id, channel_id);
|
||||
assert_eq!(channels[1].subscribers, 1);
|
||||
|
||||
mgr.release_session(session_id);
|
||||
mgr.sync_scheduler_channels(rig_id, &[]);
|
||||
|
||||
let channels = mgr.channels(rig_id);
|
||||
assert_eq!(channels.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-frontend-rigctl"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-frontend = { path = ".." }
|
||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationContext) {
|
||||
use trx_frontend::FrontendSpawner;
|
||||
context.register_frontend("rigctl", server::RigctlFrontend::spawn_frontend);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use trx_protocol::{mode_to_string, parse_mode};
|
||||
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::state::RigSnapshot;
|
||||
use trx_core::{RigCommand, RigMode, RigRequest, RigState};
|
||||
use trx_frontend::FrontendSpawner;
|
||||
|
||||
/// rigctl-compatible frontend.
|
||||
///
|
||||
/// This exposes a small subset of the rigctl/rigctld ASCII protocol to allow
|
||||
/// existing tooling to drive the rig. The implementation is intentionally
|
||||
/// minimal and only covers the operations supported by the core rig task.
|
||||
pub struct RigctlFrontend;
|
||||
|
||||
impl FrontendSpawner for RigctlFrontend {
|
||||
fn spawn_frontend(
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
_callsign: Option<String>,
|
||||
listen_addr: SocketAddr,
|
||||
context: Arc<trx_frontend::FrontendRuntimeContext>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = serve(listen_addr, state_rx, rig_tx, context).await {
|
||||
error!("rigctl server error: {:?}", e);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
listen_addr: SocketAddr,
|
||||
state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
context: Arc<trx_frontend::FrontendRuntimeContext>,
|
||||
) -> std::io::Result<()> {
|
||||
if let Ok(mut slot) = context.rigctl_listen_addr.lock() {
|
||||
*slot = Some(listen_addr);
|
||||
}
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
info!("rigctl frontend listening on {}", listen_addr);
|
||||
info!("rigctl frontend ready (rigctld-compatible)");
|
||||
|
||||
loop {
|
||||
let (stream, addr) = listener.accept().await?;
|
||||
info!("rigctl client connected: {}", addr);
|
||||
let state_rx = state_rx.clone();
|
||||
let rig_tx = rig_tx.clone();
|
||||
let context = context.clone();
|
||||
context.rigctl_clients.fetch_add(1, Ordering::Relaxed);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(stream, addr, state_rx, rig_tx).await {
|
||||
warn!("rigctl client {} error: {:?}", addr, e);
|
||||
}
|
||||
context.rigctl_clients.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
stream: TcpStream,
|
||||
addr: SocketAddr,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
rig_tx: mpsc::Sender<RigRequest>,
|
||||
) -> std::io::Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes_read = reader.read_line(&mut line).await?;
|
||||
if bytes_read == 0 {
|
||||
debug!("rigctl client {} disconnected", addr);
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match process_command(trimmed, &mut state_rx, &rig_tx).await {
|
||||
CommandResult::Reply(resp) => writer.write_all(resp.as_bytes()).await?,
|
||||
CommandResult::Close => break,
|
||||
}
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum CommandResult {
|
||||
Reply(String),
|
||||
Close,
|
||||
}
|
||||
|
||||
async fn process_command(
|
||||
cmd_line: &str,
|
||||
state_rx: &mut watch::Receiver<RigState>,
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
) -> CommandResult {
|
||||
debug!("rigctl command: {}", cmd_line);
|
||||
let mut parts = cmd_line.split_whitespace();
|
||||
let Some(raw_op) = parts.next() else {
|
||||
return CommandResult::Reply(err_response("empty command"));
|
||||
};
|
||||
let extended = raw_op.starts_with('+');
|
||||
let op = raw_op.trim_start_matches('+').trim_end_matches(':');
|
||||
|
||||
let resp = match op {
|
||||
"q" | "Q" | "\\q" | "\\quit" => return CommandResult::Close,
|
||||
"f" | "\\get_freq" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response(op, extended, [snapshot.status.freq.hz.to_string()]),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"F" | "\\set_freq" => match parts.next().and_then(parse_freq_hz_arg) {
|
||||
Some(freq) => match send_set_freq_with_compat_retry(rig_tx, freq).await {
|
||||
Ok(_) => ok_only(op, extended),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
None => err_response("expected frequency in Hz"),
|
||||
},
|
||||
"l" | "\\get_level" => {
|
||||
// Hamlib may probe optional levels during open (e.g. KEYSPD).
|
||||
// Return a benign default to keep client compatibility.
|
||||
let _level_name = parts.next();
|
||||
ok_response(op, extended, ["0"])
|
||||
}
|
||||
"m" | "\\get_mode" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => {
|
||||
let mode = rig_mode_to_str(&snapshot.status.mode);
|
||||
ok_response(op, extended, [mode, "0".to_string()])
|
||||
}
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"M" | "\\set_mode" => {
|
||||
let Some(mode_str) = parts.next() else {
|
||||
return CommandResult::Reply(err_response("expected mode"));
|
||||
};
|
||||
let mode = parse_mode(mode_str);
|
||||
match send_rig_command(rig_tx, RigCommand::SetMode(mode)).await {
|
||||
Ok(_) => ok_only(op, extended),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
"t" | "\\get_ptt" | "get_ptt" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response(
|
||||
op,
|
||||
extended,
|
||||
[if snapshot.status.tx_en { "1" } else { "0" }.to_string()],
|
||||
),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"T" | "\\set_ptt" | "set_ptt" => match parse_ptt_tokens(parts.collect()) {
|
||||
Some(v) => {
|
||||
let snapshot = match current_snapshot(state_rx) {
|
||||
Some(s) => s,
|
||||
None => match request_snapshot(rig_tx).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return CommandResult::Reply(err_response(&e)),
|
||||
},
|
||||
};
|
||||
if !rig_supports_ptt(&snapshot) {
|
||||
return CommandResult::Reply(err_response("PTT not supported"));
|
||||
}
|
||||
|
||||
match parse_ptt_arg(&v) {
|
||||
Some(ptt) => {
|
||||
debug!("rigctl ptt request: cmd='{}' parsed_ptt={}", cmd_line, ptt);
|
||||
match send_rig_command(rig_tx, RigCommand::SetPtt(ptt)).await {
|
||||
Ok(_) => ok_only(op, extended),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
None => err_response("expected PTT state (0/1)"),
|
||||
}
|
||||
}
|
||||
_ => err_response("expected PTT state (0/1)"),
|
||||
},
|
||||
"v" | "\\get_vfo" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response(op, extended, [active_vfo_label(&snapshot)]),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"V" | "\\set_vfo" => {
|
||||
let Some(target) = parts.next() else {
|
||||
return CommandResult::Reply(err_response("expected VFO (VFOA/VFOB)"));
|
||||
};
|
||||
match set_vfo_target(target, rig_tx).await {
|
||||
Ok(()) => ok_only(op, extended),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
"s" | "\\get_split_vfo" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => {
|
||||
// split state, tx vfo
|
||||
ok_response(op, extended, ["0".to_string(), active_vfo_label(&snapshot)])
|
||||
}
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"S" | "\\set_split_vfo" => match parts.next() {
|
||||
Some(v) if is_false(v) => ok_only(op, extended),
|
||||
Some(v) if is_true(v) => err_response("split mode not supported"),
|
||||
_ => err_response("expected split state (0/1)"),
|
||||
},
|
||||
"\\get_info" => {
|
||||
let snapshot = match current_snapshot(state_rx) {
|
||||
Some(s) => s,
|
||||
None => match request_snapshot(rig_tx).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return CommandResult::Reply(err_response(&e)),
|
||||
},
|
||||
};
|
||||
let info = format!(
|
||||
"Model: {} {}; Version: {}",
|
||||
snapshot.info.manufacturer, snapshot.info.model, snapshot.info.revision
|
||||
);
|
||||
ok_response(op, extended, [info])
|
||||
}
|
||||
"\\get_powerstat" | "get_powerstat" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => {
|
||||
let val = snapshot.enabled.unwrap_or(false);
|
||||
ok_response(op, extended, [if val { "1" } else { "0" }.to_string()])
|
||||
}
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"\\chk_vfo" | "chk_vfo" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response(op, extended, [active_vfo_label(&snapshot)]),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"\\dump_state" | "dump_state" => match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => ok_response(op, extended, dump_state_lines(&snapshot)),
|
||||
Err(e) => err_response(&e),
|
||||
},
|
||||
"1" | "\\dump_caps" | "dump_caps" | "\\dumpcaps" | "dumpcaps" => {
|
||||
match request_snapshot(rig_tx).await {
|
||||
Ok(snapshot) => dump_caps_response(op, extended, &snapshot),
|
||||
Err(e) => err_response(&e),
|
||||
}
|
||||
}
|
||||
"i" | "I" => {
|
||||
let snapshot = match current_snapshot(state_rx) {
|
||||
Some(s) => s,
|
||||
None => match request_snapshot(rig_tx).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return CommandResult::Reply(err_response(&e)),
|
||||
},
|
||||
};
|
||||
let info_line = format!("{} {}", snapshot.info.manufacturer, snapshot.info.model);
|
||||
ok_response(op, extended, [info_line])
|
||||
}
|
||||
_ => {
|
||||
warn!("rigctl unsupported command: {}", cmd_line);
|
||||
err_response("unsupported command")
|
||||
}
|
||||
};
|
||||
|
||||
CommandResult::Reply(resp)
|
||||
}
|
||||
|
||||
fn ok_response<I, S>(op: &str, extended: bool, lines: I) -> String
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
if extended {
|
||||
let mut resp = String::new();
|
||||
for line in lines {
|
||||
resp.push_str(op);
|
||||
resp.push_str(": ");
|
||||
resp.push_str(&line.into());
|
||||
resp.push('\n');
|
||||
}
|
||||
resp.push_str("RPRT 0\n");
|
||||
resp
|
||||
} else {
|
||||
let mut resp = String::new();
|
||||
for line in lines {
|
||||
let line = line.into();
|
||||
if !line.is_empty() {
|
||||
resp.push_str(&line);
|
||||
resp.push('\n');
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_only(op: &str, extended: bool) -> String {
|
||||
if extended {
|
||||
format!("{op}:\nRPRT 0\n")
|
||||
} else {
|
||||
"RPRT 0\n".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn err_response(msg: &str) -> String {
|
||||
warn!("rigctl command error: {}", msg);
|
||||
"RPRT -1\n".to_string()
|
||||
}
|
||||
|
||||
fn rig_supports_ptt(snapshot: &RigSnapshot) -> bool {
|
||||
snapshot.status.tx.is_some()
|
||||
|| snapshot
|
||||
.info
|
||||
.capabilities
|
||||
.supported_bands
|
||||
.iter()
|
||||
.any(|b| b.tx_allowed)
|
||||
}
|
||||
|
||||
async fn request_snapshot(rig_tx: &mpsc::Sender<RigRequest>) -> Result<RigSnapshot, String> {
|
||||
send_rig_command(rig_tx, RigCommand::GetSnapshot).await
|
||||
}
|
||||
|
||||
async fn send_rig_command(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
cmd: RigCommand,
|
||||
) -> Result<RigSnapshot, String> {
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
rig_tx
|
||||
.send(RigRequest {
|
||||
cmd,
|
||||
respond_to: resp_tx,
|
||||
rig_id_override: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("failed to send to rig: {e:?}"))?;
|
||||
|
||||
match timeout(Duration::from_secs(15), resp_rx).await {
|
||||
Ok(Ok(Ok(snapshot))) => Ok(snapshot),
|
||||
Ok(Ok(Err(err))) => Err(err.message),
|
||||
Ok(Err(e)) => Err(format!("rig response error: {e:?}")),
|
||||
Err(_) => Err("rig response timeout".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_set_freq_with_compat_retry(
|
||||
rig_tx: &mpsc::Sender<RigRequest>,
|
||||
freq_hz: u64,
|
||||
) -> Result<RigSnapshot, String> {
|
||||
match send_rig_command(rig_tx, RigCommand::SetFreq(Freq { hz: freq_hz })).await {
|
||||
Ok(snapshot) => Ok(snapshot),
|
||||
Err(e) => {
|
||||
// FT-817 backend requires 10 Hz alignment; some hamlib clients submit
|
||||
// values with 1 Hz granularity.
|
||||
if e.contains("multiple of 10 Hz") {
|
||||
let rounded = ((freq_hz + 5) / 10) * 10;
|
||||
if rounded != freq_hz {
|
||||
return send_rig_command(rig_tx, RigCommand::SetFreq(Freq { hz: rounded }))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_snapshot(state_rx: &watch::Receiver<RigState>) -> Option<RigSnapshot> {
|
||||
state_rx.borrow().snapshot()
|
||||
}
|
||||
|
||||
fn rig_mode_to_str(mode: &RigMode) -> String {
|
||||
mode_to_string(mode).into_owned()
|
||||
}
|
||||
|
||||
fn dump_state_lines(snapshot: &RigSnapshot) -> Vec<String> {
|
||||
// Hamlib expects a long, fixed sequence of bare values.
|
||||
// To maximize compatibility, mirror the ordering produced by hamlib's dummy backend.
|
||||
// Some Hamlib/netrigctl versions expect a trailing `done` sentinel.
|
||||
let mut lines = vec![
|
||||
"1".to_string(),
|
||||
"1".to_string(),
|
||||
"0".to_string(),
|
||||
"150000.000000 1500000000.000000 0x1ff -1 -1 0x17e00007 0xf".to_string(),
|
||||
"0 0 0 0 0 0 0".to_string(),
|
||||
"150000.000000 1500000000.000000 0x1ff 5000 100000 0x17e00007 0xf".to_string(),
|
||||
"0 0 0 0 0 0 0".to_string(),
|
||||
"0x1ff 1".to_string(),
|
||||
"0x1ff 0".to_string(),
|
||||
"0 0".to_string(),
|
||||
"0xc 2400".to_string(),
|
||||
"0xc 1800".to_string(),
|
||||
"0xc 3000".to_string(),
|
||||
"0xc 0".to_string(),
|
||||
"0x2 500".to_string(),
|
||||
"0x2 2400".to_string(),
|
||||
"0x2 50".to_string(),
|
||||
"0x2 0".to_string(),
|
||||
"0x10 300".to_string(),
|
||||
"0x10 2400".to_string(),
|
||||
"0x10 50".to_string(),
|
||||
"0x10 0".to_string(),
|
||||
"0x1 8000".to_string(),
|
||||
"0x1 2400".to_string(),
|
||||
"0x1 10000".to_string(),
|
||||
"0x20 15000".to_string(),
|
||||
"0x20 8000".to_string(),
|
||||
"0x40 230000".to_string(),
|
||||
"0 0".to_string(),
|
||||
"9990".to_string(),
|
||||
"9990".to_string(),
|
||||
"10000".to_string(),
|
||||
"0".to_string(),
|
||||
"10 ".to_string(),
|
||||
"10 20 30 ".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
if rig_supports_ptt(snapshot) {
|
||||
"0xffffffffffffffff".to_string()
|
||||
} else {
|
||||
"0x0".to_string()
|
||||
},
|
||||
"0xffffffffffffffff".to_string(),
|
||||
"0xffffffffffffffff".to_string(),
|
||||
];
|
||||
lines.push("done".to_string());
|
||||
lines
|
||||
}
|
||||
|
||||
fn dump_caps_response(op: &str, extended: bool, snapshot: &RigSnapshot) -> String {
|
||||
// netrigctl_open expects `setting=value` lines terminated by `done`.
|
||||
// Unknown keys are tolerated by Hamlib, but malformed lines are not.
|
||||
let mut resp = String::new();
|
||||
let push = |buf: &mut String, key: &str, val: String| {
|
||||
buf.push_str(key);
|
||||
buf.push('=');
|
||||
buf.push_str(&val);
|
||||
buf.push('\n');
|
||||
};
|
||||
|
||||
push(&mut resp, "protocol_version", "1".to_string());
|
||||
push(&mut resp, "rig_model", "2".to_string());
|
||||
push(&mut resp, "model_name", snapshot.info.model.clone());
|
||||
push(&mut resp, "mfg_name", snapshot.info.manufacturer.clone());
|
||||
push(&mut resp, "backend_version", snapshot.info.revision.clone());
|
||||
push(
|
||||
&mut resp,
|
||||
"vfo_count",
|
||||
snapshot.info.capabilities.num_vfos.to_string(),
|
||||
);
|
||||
push(
|
||||
&mut resp,
|
||||
"has_vfo_b",
|
||||
if snapshot.info.capabilities.num_vfos >= 2 {
|
||||
"1".to_string()
|
||||
} else {
|
||||
"0".to_string()
|
||||
},
|
||||
);
|
||||
push(
|
||||
&mut resp,
|
||||
"can_ptt",
|
||||
if rig_supports_ptt(snapshot) {
|
||||
"1".to_string()
|
||||
} else {
|
||||
"0".to_string()
|
||||
},
|
||||
);
|
||||
resp.push_str("done\n");
|
||||
if extended {
|
||||
ok_response(
|
||||
op,
|
||||
true,
|
||||
resp.lines().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
fn active_vfo_label(snapshot: &RigSnapshot) -> String {
|
||||
// Normalize to VFOA/VFOB/... for hamlib compatibility.
|
||||
snapshot
|
||||
.status
|
||||
.vfo
|
||||
.as_ref()
|
||||
.and_then(|vfo| vfo.active)
|
||||
.map(|idx| {
|
||||
let letter = (b'A' + (idx as u8)) as char;
|
||||
format!("VFO{}", letter)
|
||||
})
|
||||
.unwrap_or_else(|| "VFOA".to_string())
|
||||
}
|
||||
|
||||
async fn set_vfo_target(target: &str, rig_tx: &mpsc::Sender<RigRequest>) -> Result<(), String> {
|
||||
let desired = normalize_vfo_name(target).ok_or_else(|| "expected VFOA or VFOB".to_string())?;
|
||||
let snapshot = request_snapshot(rig_tx).await?;
|
||||
let current = active_vfo_label(&snapshot);
|
||||
if current == desired {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supports_toggle = snapshot.info.capabilities.num_vfos >= 2
|
||||
&& snapshot
|
||||
.status
|
||||
.vfo
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.entries.len() >= 2);
|
||||
if !supports_toggle {
|
||||
return Err("VFO selection not supported".to_string());
|
||||
}
|
||||
|
||||
send_rig_command(rig_tx, RigCommand::ToggleVfo).await?;
|
||||
let after = request_snapshot(rig_tx).await?;
|
||||
if active_vfo_label(&after) == desired {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("failed to switch VFO".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_vfo_name(v: &str) -> Option<String> {
|
||||
match v.trim().to_ascii_uppercase().as_str() {
|
||||
"VFOA" | "A" => Some("VFOA".to_string()),
|
||||
"VFOB" | "B" => Some("VFOB".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_true(s: &str) -> bool {
|
||||
matches!(s, "1" | "on" | "ON" | "true" | "True" | "TRUE")
|
||||
}
|
||||
|
||||
fn is_false(s: &str) -> bool {
|
||||
matches!(s, "0" | "off" | "OFF" | "false" | "False" | "FALSE")
|
||||
}
|
||||
|
||||
fn parse_ptt_arg(s: &str) -> Option<bool> {
|
||||
let normalized = s.trim().trim_end_matches(';').trim_end_matches(',');
|
||||
if is_true(normalized) {
|
||||
return Some(true);
|
||||
}
|
||||
if is_false(normalized) {
|
||||
return Some(false);
|
||||
}
|
||||
|
||||
// Hamlib may send enum-like numeric values where non-zero means ON.
|
||||
if let Ok(v) = normalized.parse::<i64>() {
|
||||
return Some(v != 0);
|
||||
}
|
||||
|
||||
match normalized.to_ascii_uppercase().as_str() {
|
||||
"ON_DATA" | "DATA" | "MIC" | "ON_MIC" => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ptt_tokens(tokens: Vec<&str>) -> Option<String> {
|
||||
match tokens.as_slice() {
|
||||
[] => None,
|
||||
[only] => Some((*only).to_string()),
|
||||
[first, second, ..] if normalize_vfo_name(first).is_some() => Some((*second).to_string()),
|
||||
_ => tokens
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| parse_ptt_arg(t).is_some())
|
||||
.copied()
|
||||
.map(str::to_string)
|
||||
.or_else(|| tokens.last().map(|s| (*s).to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_freq_hz_arg(s: &str) -> Option<u64> {
|
||||
if let Ok(hz) = s.parse::<u64>() {
|
||||
return Some(hz);
|
||||
}
|
||||
|
||||
let mut hz = s.parse::<f64>().ok()?;
|
||||
if !hz.is_finite() || hz <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Some rigctl clients send MHz as a decimal float (e.g. "7.100000").
|
||||
// Heuristic: if decimal value is below 1 MHz, interpret as MHz.
|
||||
if s.contains('.') && hz < 1_000_000.0 {
|
||||
hz *= 1_000_000.0;
|
||||
}
|
||||
|
||||
if hz > (u64::MAX as f64) {
|
||||
return None;
|
||||
}
|
||||
Some(hz.round() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo, RigStatus, RigTxStatus};
|
||||
|
||||
fn test_snapshot() -> RigSnapshot {
|
||||
RigSnapshot {
|
||||
info: RigInfo {
|
||||
manufacturer: "TRX".to_string(),
|
||||
model: "Virtual".to_string(),
|
||||
revision: "0.1.0".to_string(),
|
||||
capabilities: RigCapabilities {
|
||||
min_freq_step_hz: 1,
|
||||
supported_bands: vec![],
|
||||
supported_modes: vec![RigMode::USB],
|
||||
num_vfos: 2,
|
||||
lock: false,
|
||||
lockable: false,
|
||||
attenuator: false,
|
||||
preamp: false,
|
||||
rit: false,
|
||||
rpt: false,
|
||||
split: false,
|
||||
tx: true,
|
||||
tx_limit: true,
|
||||
vfo_switch: true,
|
||||
filter_controls: false,
|
||||
signal_meter: true,
|
||||
},
|
||||
access: RigAccessMethod::Tcp {
|
||||
addr: "127.0.0.1:4532".to_string(),
|
||||
},
|
||||
},
|
||||
status: RigStatus {
|
||||
freq: Freq { hz: 7_100_000 },
|
||||
mode: RigMode::USB,
|
||||
tx_en: false,
|
||||
vfo: None,
|
||||
tx: Some(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
}),
|
||||
rx: None,
|
||||
lock: None,
|
||||
},
|
||||
band: None,
|
||||
enabled: Some(true),
|
||||
initialized: true,
|
||||
server_callsign: None,
|
||||
server_version: None,
|
||||
server_build_date: None,
|
||||
server_latitude: None,
|
||||
server_longitude: None,
|
||||
pskreporter_status: None,
|
||||
aprs_is_status: None,
|
||||
decoders: trx_core::DecoderConfig::default(),
|
||||
cw_auto: false,
|
||||
cw_wpm: 0,
|
||||
cw_tone_hz: 0,
|
||||
filter: None,
|
||||
spectrum: None,
|
||||
vchan_rds: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_caps_is_setting_value_and_ends_with_done() {
|
||||
let response = dump_caps_response("dump_caps", false, &test_snapshot());
|
||||
let lines: Vec<&str> = response.lines().collect();
|
||||
assert!(lines
|
||||
.iter()
|
||||
.all(|line| *line == "done" || line.contains('=')));
|
||||
assert_eq!(lines.last(), Some(&"done"));
|
||||
assert!(response.contains("model_name=Virtual\n"));
|
||||
assert!(response.contains("mfg_name=TRX\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_response_does_not_append_rprt_status() {
|
||||
let response = ok_response("f", false, ["7100000"]);
|
||||
assert_eq!(response, "7100000\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_response_extended_includes_command_prefix_and_status() {
|
||||
let response = ok_response("\\get_freq", true, ["7100000"]);
|
||||
assert_eq!(response, "\\get_freq: 7100000\nRPRT 0\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freq_hz_arg_accepts_integer_and_decimal() {
|
||||
assert_eq!(parse_freq_hz_arg("7100000"), Some(7_100_000));
|
||||
assert_eq!(parse_freq_hz_arg("7100000.000000"), Some(7_100_000));
|
||||
assert_eq!(parse_freq_hz_arg("7.100000"), Some(7_100_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ptt_arg_accepts_common_hamlib_values() {
|
||||
assert_eq!(parse_ptt_arg("0"), Some(false));
|
||||
assert_eq!(parse_ptt_arg("1"), Some(true));
|
||||
assert_eq!(parse_ptt_arg("2"), Some(true));
|
||||
assert_eq!(parse_ptt_arg("OFF"), Some(false));
|
||||
assert_eq!(parse_ptt_arg("ON"), Some(true));
|
||||
assert_eq!(parse_ptt_arg("DATA"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ptt_tokens_accepts_optional_vfo_prefix() {
|
||||
assert_eq!(parse_ptt_tokens(vec!["1"]), Some("1".to_string()));
|
||||
assert_eq!(parse_ptt_tokens(vec!["VFOA", "1"]), Some("1".to_string()));
|
||||
assert_eq!(
|
||||
parse_ptt_tokens(vec!["VFOB", "ON_DATA"]),
|
||||
Some("ON_DATA".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||