Compare commits

...
Author SHA1 Message Date
sjgandClaude Opus 5 86dd36312e [test](trx-frontend-http): type the frequency instead of filling it
CI / lint (pull_request) Successful in 2m20s
CI / test (pull_request) Successful in 8m32s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m26s
CI / test (push) Successful in 7m42s
CI / frontend (push) Successful in 3m35s
CI / reuse (push) Successful in 5s
tune-links drove the dial with Playwright's fill(), which writes a value
into the field without a keystroke.  The app arms its guard against its own
refreshes on the first keydown, so a filled field stays unguarded: any state
update landing between the fill and the Enter rewrites the field with the
frequency the radio is already on, and the Enter then re-applies that.  The
window is a few milliseconds wide on a developer's machine and wide enough
to lose on a loaded CI runner, where the test failed claiming the tuning had
landed on the frequency it started from.

Type it the way an operator does: select the field, then send the characters
as keystrokes.  The select arms the guard before a single character changes.

Under CPU throttling that reproduced the failure — 1 in 6 runs with fill(),
on this branch and on main alike — typing came through 8 runs clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 02:47:04 +02:00
sjgandClaude Opus 5 15ff686542 [fix](trx-frontend-http): keep the mini views to the rig on screen
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 8m34s
CI / frontend (pull_request) Failing after 1m32s
CI / reuse (pull_request) Successful in 6s
The decode SSE stream and the history behind it are not rig-scoped: every
rig's decodes reach the browser, each carrying the rig that heard it.  The
panels on the decoder tabs want that — they aggregate the whole station —
but the mini views over the waterfall caption the spectrum underneath, and
they were reading the same unfiltered histories.  A background rig copying
APRS on another band put its frames over the active rig's waterfall.  The
mode gate did not help: it reads the mode of the rig on screen, so those
frames appeared whenever that rig happened to be in PKT.

Filter each overlay on the rig it belongs to, through one shared predicate
that compares a decode's rig_id with the per-tab active rig already driving
the spectrum and the audio.  A decode that names no rig, and a session that
has not learnt its rig list yet, still show everything.

The FTx normalizer was dropping rig_id on the floor, so it now keeps it.
CW needed more than a filter: its lines accumulate character by character,
so two rigs copying at once braided their text into one unreadable line.
Lines in progress are now kept per rig.

The bar repaints in render() move into refreshDecodeBars(), which the rig
switch calls as well — otherwise the outgoing rig's frames stayed on screen
until the next state update — and which finally includes the CW bar.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:46:39 +02:00
sjgandClaude Opus 5 b78c4a4dd4 [feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

**It was polled, one round trip per frame.** The client asked for a frame every
50 ms on a dedicated connection and waited for the reply, so the frame rate was
capped at 1/RTT — on a 200 ms link, five frames a second no matter what was
configured.  Add SubscribeSpectrum alongside the existing SubscribeMeter: the
server pushes frames from a per-rig broadcast that rig_task fills only while
somebody is subscribed.  A server too old to know the command answers with an
error and leaves the connection usable, so the client falls back to polling on
the same connection without reconnecting.

**Bins were JSON floats.** 1024 bins spelled out as decimal text is around
10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to
browser, already sends the same information as base64 i8 in about 1.4 KB.  Bins
now travel base64-encoded whole dBFS, the resolution the display draws at
anyway.  Decoding still accepts the old array form.

**Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms
replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms
lets the client ask for less.  512 bins at 5 frames/s is roughly 3.5 KB/s
against roughly 200 KB/s before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:15:50 +02:00
sjgandClaude Opus 5 f396e8f235 [fix](trx-frontend-http): give satellite passes their own page
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m50s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
Pass predictions were a third view inside the Weather Satellite Decoder card,
under Digital modes — a planning tool filed behind a decoder toggle, beside the
FT8 and WEFAX panels it has nothing to do with.  Nothing about knowing when a
bird comes over belongs there.

Move them to /satellites, reached from Tools alongside Statistics, Recorder,
Settings and About: occasional destinations that live behind that menu rather
than taking a slot in the operating strip.  Adding a sixth strip button wrapped
the phone nav onto two rows and cost the desktop strip its labels at 1280px, so
the tab is hidden from the strip exactly the way its four peers already are —
the nav is byte-for-byte what it was.

The prediction code moves out of sat.ts into its own plugin that loads with the
page, so the decoder card no longer carries it.  Countdowns stop when the page
is hidden and each visit reloads, since passes go stale while it is closed.
The server grows a /satellites index route so a deep link or a refresh serves
the SPA shell rather than a 404.

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:06:00 +02:00
58 changed files with 2035 additions and 648 deletions
Generated
+1
View File
@@ -3165,6 +3165,7 @@ dependencies = [
name = "trx-core"
version = "0.1.0"
dependencies = [
"base64",
"flate2",
"reqwest",
"serde",
+43
View File
@@ -153,6 +153,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 1288192 |
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
Spectrum is the largest thing on the client connection. On a slow or
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
#### `[sdr.gain]`
@@ -295,6 +302,7 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
#### `[remote.auth]`
@@ -394,6 +402,41 @@ older single `port` key and `--rigctl-port` are ignored.
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### Spectrum over a slow link
Spectrum dominates the server↔client connection: everything else is a few
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
**Frames are pushed, not polled.** The client subscribes and the server sends
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
second however often the client asked. Clients fall back to polling
automatically against a server too old to stream.
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
an eighth of the JSON array of floats they used to be, at the resolution the
display draws anyway.
**Both ends have a rate, and the slower one wins.** The server pushes no faster
than `[sdr].spectrum_interval_ms`; the client asks for no more than
`[[remotes]].spectrum_interval_ms`.
For a link that struggles, start here:
```toml
[trx-server.sdr]
spectrum_fft_size = 512 # half the bins, half the bytes
spectrum_interval_ms = 200 # 5 frames/s instead of 20
[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200
```
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary
**trx-server:**
+10
View File
@@ -288,6 +288,7 @@ async fn async_init() -> DynResult<AppState> {
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 {
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
name,
url: url.clone(),
rig_id,
@@ -483,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
.map(|e| e.poll_interval_ms)
.min()
.unwrap_or(750);
// Entries sharing a server share its connections, so the most frequent
// request wins: whoever wants spectrum fastest sets the rate.
let spectrum_interval = entries
.iter()
.map(|e| e.spectrum_interval_ms)
.min()
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
for entry in entries {
@@ -496,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval),
spectrum_interval: Duration::from_millis(spectrum_interval),
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(),
+271 -5
View File
@@ -20,7 +20,7 @@ use trx_core::{RigError, RigResult};
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
// Endpoint parsing lives in `trx-config` so config validation and the
// connection code agree on what a URL means.
@@ -32,9 +32,10 @@ const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
// Keep remote spectrum reasonably responsive without returning to the old
// timeout churn caused by a much tighter request cadence.
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
// Default spectrum cadence when a config does not specify one. Both the push
// stream and the poll fallback run at the configured rate; see
// `[[remotes]].spectrum_interval_ms`.
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone)]
pub struct RemoteClientConfig {
@@ -43,6 +44,12 @@ pub struct RemoteClientConfig {
pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration,
/// How often spectrum frames are wanted. Drives the poll fallback and is
/// the rate the client asks the server to push at.
pub spectrum_interval: Duration,
/// Set once a server has rejected `SubscribeSpectrum`, so later
/// connections to it go straight to polling instead of asking again.
pub spectrum_stream_unsupported: Arc<AtomicBool>,
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
/// Shared flag: `true` while a TCP connection to trx-server is active.
@@ -491,6 +498,100 @@ async fn send_get_sat_passes_on(
))
}
/// What ended a spectrum stream attempt.
enum SpectrumStreamOutcome {
/// The stream ran and is over; the connection is spent.
Finished,
/// The server rejected the subscription. The connection is still usable,
/// so the caller can poll on it.
Unsupported,
}
/// Subscribe to the server's spectrum push for one rig and publish frames as
/// they arrive.
///
/// The server answers either with frames or, when it is too old to know the
/// command, with an error response — which leaves the connection usable for
/// polling, so falling back costs no reconnect.
async fn run_spectrum_stream(
config: &RemoteClientConfig,
writer: &mut (impl AsyncWriteExt + Unpin),
reader: &mut (impl AsyncBufRead + Unpin),
short_name: &str,
shutdown_rx: &mut watch::Receiver<bool>,
) -> RigResult<SpectrumStreamOutcome> {
let envelope = build_envelope(
config,
ClientCommand::SubscribeSpectrum,
Some(short_name.to_string()),
);
let mut payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
.await
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
// Re-check what the UI wants: switching rigs has to end this stream so the
// connection can be rebuilt for the new one.
let mut supervisor = time::interval(Duration::from_millis(500));
supervisor.tick().await;
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
Ok(()) => {}
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
}
}
_ = supervisor.tick() => {
let wanted = active_spectrum_rig_ids(config);
if wanted.len() != 1 || wanted[0] != short_name {
return Ok(SpectrumStreamOutcome::Finished);
}
}
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
let line = line
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
.ok_or_else(|| {
RigError::communication("spectrum connection closed".to_string())
})?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<SpectrumFrame>(trimmed) {
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
// Anything that is not a frame means the server would rather
// answer than stream: an older build rejecting the command.
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
}
}
}
}
}
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
if let Ok(map) = config.rig_spectrums.read() {
if let Some(tx) = map.get(short_name) {
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
}
}
if selected_rig_id(config).as_deref() == Some(short_name) {
config
.spectrum
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
}
}
async fn handle_spectrum_connection(
config: &RemoteClientConfig,
stream: TcpStream,
@@ -498,7 +599,33 @@ async fn handle_spectrum_connection(
) -> RigResult<()> {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
// Prefer the push stream: polling costs a round trip per frame, so on a
// high-latency link the frame rate is 1/RTT no matter what interval is
// configured. It only works for one rig per connection, and only against
// a server new enough to understand the command.
let streamable = active_spectrum_rig_ids(config);
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
match run_spectrum_stream(
config,
&mut writer,
&mut reader,
&streamable[0],
shutdown_rx,
)
.await?
{
SpectrumStreamOutcome::Finished => return Ok(()),
SpectrumStreamOutcome::Unsupported => {
info!("Server does not support spectrum streaming; falling back to polling");
config
.spectrum_stream_unsupported
.store(true, Ordering::Relaxed);
}
}
}
let mut interval = time::interval(config.spectrum_interval);
// Cache the token outside the poll loop to avoid cloning it every 50ms.
let cached_token = config.token.clone();
@@ -1187,6 +1314,7 @@ mod tests {
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
use super::{
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
DEFAULT_SPECTRUM_INTERVAL,
};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
@@ -1381,6 +1509,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(100),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1426,6 +1556,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1441,6 +1573,130 @@ mod tests {
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
}
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
super::RemoteClientConfig {
addr: "127.0.0.1:4530".to_string(),
token: None,
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
rig_states: Arc::new(RwLock::new(HashMap::new())),
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_id_to_short_name: HashMap::new(),
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_meters: Arc::new(RwLock::new(HashMap::new())),
}
}
/// A pushed frame reaches the watch channel the UI reads, with no request
/// from the client beyond the initial subscribe.
#[tokio::test]
async fn spectrum_stream_publishes_pushed_frames() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
// The server sees the subscribe, then pushes without being asked.
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
assert!(
subscribe.contains("subscribe_spectrum"),
"unexpected command: {subscribe}"
);
let frame = trx_protocol::SpectrumFrame {
rig_id: "sdr".to_string(),
spectrum: trx_core::rig::state::SpectrumData {
bins: vec![-70.0, -30.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = serde_json::to_string(&frame).unwrap();
line.push('\n');
server_io.write_all(line.as_bytes()).await.expect("push");
server_io.flush().await.expect("flush");
spectrum_rx.changed().await.expect("spectrum published");
let published = spectrum_rx.borrow().clone();
let spectrum = published.frame.expect("spectrum present");
assert_eq!(spectrum.center_hz, 14_200_000);
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
let _ = shutdown_tx.send(true);
let _ = task.await;
}
/// An older server answers the unknown command with an error instead of
/// frames. That has to read as "poll instead", not as a dead connection.
#[tokio::test]
async fn spectrum_stream_falls_back_when_unsupported() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
server_io
.write_all(
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
)
.await
.expect("error response");
server_io.flush().await.expect("flush");
let outcome = task.await.expect("join").expect("stream result");
assert!(
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
"an error response should fall back to polling"
);
}
#[test]
fn build_envelope_translates_short_name_to_server_rig_id() {
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
@@ -1454,6 +1710,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1486,6 +1744,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1510,6 +1770,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1550,6 +1812,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: known_rigs.clone(),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1623,6 +1887,8 @@ mod tests {
selected_rig_id,
known_rigs,
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore,
hostState
@@ -222,7 +225,9 @@ function updateAisBar() {
updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -885,7 +885,7 @@ function elementById(id) {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -1529,6 +1529,7 @@ var TAB_ORDER = [
"bookmarks",
"digital-modes",
"map",
"satellites",
"statistics",
"recorder",
"settings",
@@ -1539,6 +1540,7 @@ var TAB_PATHS = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -1756,6 +1758,7 @@ var pluginGroups = {
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -3552,6 +3555,13 @@ function positionRdsPsOverlay() {
function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
primaryRds = null;
vchanRdsById = /* @__PURE__ */ new Map();
@@ -3566,6 +3576,7 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
refreshDecodeBars();
}
function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return;
@@ -4785,10 +4796,7 @@ function render(update) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
refreshDecodeBars();
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
@@ -5844,6 +5852,7 @@ function _initMapWhenReady() {
}
function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -5873,7 +5882,10 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error) => {
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.();
}).catch((error) => {
console.error(error);
});
if (name === "map") {
@@ -6320,9 +6332,6 @@ function _wireSubTabBar(bar) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -5,6 +5,9 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore,
hostState
@@ -153,7 +156,9 @@ 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 ?? 0) >= cutoffMs);
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
);
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) {
@@ -0,0 +1,14 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/active-rig.ts
function isActiveRigDecode(rigId) {
const activeRigId = hostState.lastActiveRigId;
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
export {
isActiveRigDecode
};
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -177,6 +180,9 @@ function initializeFtxDecoder(config) {
});
}
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -202,7 +208,7 @@ function initializeFtxDecoder(config) {
bridge.clearMapMarkersByType?.(id);
};
const barFrames = () => {
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0;
var cwTonePickerRaf = null;
var cwBarHistory = [];
var cwBarCurrentLine = null;
var cwBarCurrentLines = /* @__PURE__ */ new Map();
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
function cwBarFlushCurrentLine(key) {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
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);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
cwBarCurrentLines.clear();
updateCwBar();
drawCwTonePicker();
}
@@ -346,18 +351,22 @@ function onServerCw(evt) {
}
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine();
cwBarFlushCurrentLine(key);
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
let line = cwBarCurrentLines.get(key);
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
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 };
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
}
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));
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-O2Y7YEVQ.js";
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
@@ -8,7 +8,6 @@ var 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"),
@@ -17,29 +16,12 @@ var satDom = {
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")
viewHistoryBtn: document.getElementById("sat-view-history")
};
var satImageHistory = [];
var SAT_MAX_IMAGES = 100;
var SAT_PRED_PAGE_SIZE = 50;
var satPredShowAll = false;
var satFilterText = "";
var satActiveView = "live";
var satPredData = [];
var satPredFilterText = "";
var satPredMinEl = 0;
var satPredCategory = "all";
var satPredSatCount = 0;
var satPredCountdownTimer = null;
function scheduleSatUi(key, job) {
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
satWindow.trxScheduleUiFrameJob(key, job);
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
job();
}
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;
void loadSatPredictions();
}
if (view === "history") renderSatHistoryTable();
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => {
switchSatView("live");
});
satDom.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history");
});
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) {
if (!satDom.lrptState) return;
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
}
})();
});
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ 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 / 1e3));
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";
}
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = 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 = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${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)}°</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)}${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)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
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());
}
var satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
var satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
var satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
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);
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);
}
}
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…</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
if (satDom.predStatus) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
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 (error) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
satWindow.satShowOnMap = function(south, west, north, east) {
if (typeof satWindow.enableMapSourceFilter === "function") {
satWindow.enableMapSourceFilter("sat");
@@ -0,0 +1,228 @@
// src/plugins/satellite-predictions.ts
var predWindow = window;
var dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter"),
minElevation: document.getElementById("sat-pred-min-el"),
category: document.getElementById("sat-pred-category"),
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status")
};
var PAGE_SIZE = 50;
var predShowAll = false;
var predData = [];
var predFilterText = "";
var predMinEl = 0;
var predCategory = "all";
var predSatCount = 0;
var predCountdownTimer = null;
function isPageVisible() {
return !!dom.page && dom.page.style.display !== "none";
}
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ 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 / 1e3));
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";
}
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${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)}°</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)}${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)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function filtered() {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
var filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
var minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
var categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
function render(passes, error) {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.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);
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
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…</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
if (dom.status) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
async function load() {
if (dom.status) dom.status.textContent = "Loading predictions…";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
predWindow.refreshSatPredictions = function() {
predShowAll = false;
void load();
};
@@ -1,3 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -137,7 +140,7 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
@@ -63,6 +64,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
<span class="tab-label">Map</span>
</button>
<button class="tab" data-tab="satellites">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
<span class="tab-label">Satellites</span>
</button>
<button class="tab" data-tab="statistics">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
<span class="tab-label">Statistics</span>
@@ -918,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="sat-view-bar">
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
</div>
<!-- Live view -->
<div id="sat-live-view">
@@ -960,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="sat-history-list"></div>
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
</div>
<!-- Predictions view -->
<div id="sat-predictions-view" style="display:none;">
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
</div>
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
@@ -1117,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</template>
</div>
<div id="tab-satellites" class="tab-panel" style="display:none;">
<h2 class="section-heading">Satellites</h2>
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
@@ -3816,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
/* One navigation model at every width. Satellites, Statistics, Recorder,
* Settings and About are occasional destinations: they live behind More rather
* than competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="satellites"],
.tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
@@ -4030,8 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.tab-icon, .tab-more-icon { display: block; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
.tab[data-tab="recorder"], .tab[data-tab="settings"],
.tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-menu {
position: fixed;
@@ -30,6 +30,7 @@ await build({
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
export type SpectrumData = {
/**
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
*
* On the wire these are base64-encoded `i8` whole dBFS (see
* `spectrum_wire`), which is what the display draws anyway; the TypeScript
* type describes the decoded array the browser receives over SSE.
*/
bins: Array<number>,
/**
@@ -385,12 +385,14 @@ declare global {
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
refreshSatPredictions?(): void;
syncWefaxToggle?(enabled: boolean): void;
syncSstvToggle?(enabled: boolean): void;
updateAisBar?(value?: number): void;
updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void;
updateFt8Bar?(value?: number): void;
updateCwBar?(value?: number): void;
updateSatLiveState?(value: unknown): void;
applyCwAutoUi?(enabled: boolean): void;
applyCwAutoUiFromServer?(enabled: boolean): void;
@@ -2206,6 +2208,17 @@ function resetRdsDisplay() {
updateRdsPsOverlay(primaryRds);
}
// The mini views over the waterfall show only what the rig on screen heard, so
// they have to be repainted whenever that rig — or its mode — changes, not just
// when the next decode happens to arrive.
function refreshDecodeBars() {
window.updateAisBar?.();
window.updateVdesBar?.();
window.updateAprsBar?.();
window.updateFt8Bar?.();
window.updateCwBar?.();
}
function resetDecoderStateOnRigSwitch() {
// RDS
primaryRds = null;
@@ -2225,6 +2238,11 @@ function resetDecoderStateOnRigSwitch() {
const el = document.getElementById(id);
if (el) el.textContent = "--";
});
// The decode stream is not rig-scoped, so the histories behind the mini views
// survive the switch: drop the outgoing rig's frames from them now instead of
// leaving them on screen until the next state update.
refreshDecodeBars();
}
function resetWfmStereoIndicator() {
@@ -3675,10 +3693,7 @@ function render(update: AppUpdate) {
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
}
if (window.updateAisBar) window.updateAisBar();
if (window.updateVdesBar) window.updateVdesBar();
if (window.updateAprsBar) window.updateAprsBar();
if (window.updateFt8Bar) window.updateFt8Bar();
refreshDecodeBars();
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
for (const d of decoderRegistry) {
if (d.activation !== "toggle") continue;
@@ -4849,6 +4864,7 @@ function _initMapWhenReady() {
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -4889,7 +4905,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
// Its countdowns tick every second, so the page stops when it is not on screen.
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
// Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
}
@@ -5241,10 +5263,6 @@ function _wireSubTabBar(bar: WiredElement) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -15,6 +17,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
// The decode panels are aggregate views on purpose: they list what every rig
// heard, and the history they restore from is not rig-scoped either. The mini
// views over the waterfall are not aggregates — they read as a caption for the
// spectrum underneath, so a frame a background rig copied on another band
// belongs to a different picture entirely. Each decode names the rig that
// heard it (the client stamps `rig_id` as the frame leaves the audio
// connection), which is all the overlays need to keep to the rig on screen.
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser
// without a rig of its own, there is nothing to disagree with.
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
export {};
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
@@ -314,7 +315,9 @@ function updateAisBar() {
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const recent = aisMessageHistory.filter(
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -179,7 +180,9 @@ function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const okFrames = aprsPacketHistory.filter(
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
);
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) {
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -20,8 +21,11 @@ interface CwRenderer {
drawPoints(points: number[], size: number, color: Rgba): void;
}
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine {
rigId: string | null; tsMs: number; ts: string; text: string;
wpm: number | null; tone_hz: number | null; lastMs: number;
}
interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string;
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf: number | null = null;
let cwBarHistory: CwLine[] = [];
let cwBarCurrentLine: CwLine | null = null;
// One line in progress per rig, keyed by rig id ("" for a decode that names no
// rig). Two rigs copying at the same time each fill their own line instead of
// braiding their characters into one.
const cwBarCurrentLines = new Map<string, CwLine>();
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
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
function cwBarFlushCurrentLine(key: string): void {
const line = cwBarCurrentLines.get(key);
cwBarCurrentLines.delete(key);
if (line?.text.trim()) {
cwBarHistory.unshift(line);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar(): void {
@@ -114,9 +122,12 @@ function updateCwBar(): void {
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.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 recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
// Prepend the in-progress lines so characters appear immediately
const inProgress = [...cwBarCurrentLines.values()]
.filter((l) => l.text && isActiveRigDecode(l.rigId))
.sort((a, b) => b.tsMs - a.tsMs);
const liveLines = [...inProgress, ...recent];
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
cwBarCurrentLines.clear();
updateCwBar();
drawCwTonePicker();
}
@@ -445,18 +456,22 @@ function onServerCw(evt: CwEvent): void {
// Bar history accumulation (regardless of pause state)
if (evt.text) {
const now = Date.now();
const rigId = evt.rig_id ?? null;
const key = rigId ?? "";
if (evt.text === "\n") {
cwBarFlushCurrentLine();
cwBarFlushCurrentLine(key);
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
let line = cwBarCurrentLines.get(key);
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine(key);
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 };
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
cwBarCurrentLines.set(key, line);
}
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));
line.text += evt.text;
line.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -15,6 +16,7 @@ export interface FtxMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -251,6 +253,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
});
}
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
rig_id: message.rig_id ?? null,
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
@@ -277,7 +282,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const barFrames = (): BarFrames => {
const recent = history
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
&& isActiveRigDecode(message.rig_id))
.slice(0, 8);
let html = "";
for (const message of recent) {
@@ -4,13 +4,12 @@
import { hostCore } from "./host.js";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
type SatelliteView = "live" | "history" | "predictions";
type SatelliteView = "live" | "history";
interface SatelliteBridge {
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
clearSatPredictionDom?: () => void;
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void;
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
// --- 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
//
// Pass predictions live on their own page; see satellite-predictions.ts.
// ── 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"),
@@ -42,30 +41,13 @@ const satDom = {
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") as HTMLInputElement | null,
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
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: SatelliteImage[] = [];
const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = "";
let satActiveView: SatelliteView = "live";
let satPredData: SatellitePass[] = [];
let satPredFilterText = "";
let satPredMinEl = 0;
let satPredCategory = "all";
let satPredSatCount = 0;
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
// ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key: string, job: () => void): void {
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
// ── View switching ──────────────────────────────────────────────────
function switchSatView(view: SatelliteView): void {
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;
void loadSatPredictions();
}
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "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 (view === "history") renderSatHistoryTable();
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.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: boolean | null = null;
@@ -335,234 +300,6 @@ document
}
})(); });
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
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: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
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: number): string {
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: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".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 = Number.parseInt(el.dataset.los ?? "0", 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: SatellitePass, now: number): HTMLElement {
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: SatellitePass): HTMLElement {
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(): SatellitePass[] {
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());
}
const satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
const satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
const satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
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(): Promise<void> {
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() as SatellitePassResponse;
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error: unknown) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Navigate to map centered on satellite image bounds ──────────────
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
if (typeof satWindow.enableMapSourceFilter === "function") {
@@ -0,0 +1,290 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
// --- Satellite pass predictions ---
//
// Its own page rather than a third view inside the weather-satellite decoder:
// when a bird comes over is a planning question, and it was buried under
// Digital modes beside decoders it has nothing to do with.
interface PredictionsBridge {
clearSatPredictionDom?: () => void;
refreshSatPredictions?: () => void;
}
const predWindow = window as unknown as PredictionsBridge;
// ── DOM references (cached once) ───────────────────────────────────
const dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
let predShowAll = false;
let predData: SatellitePass[] = [];
let predFilterText = "";
let predMinEl = 0;
let predCategory = "all";
let predSatCount = 0;
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
/** The countdowns tick once a second, so they stop when nobody is looking. */
function isPageVisible(): boolean {
return !!dom.page && dom.page.style.display !== "none";
}
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
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: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
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: number): string {
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 (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
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: SatellitePass): HTMLElement {
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 filtered(): SatellitePass[] {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
const filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
const minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
const categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function render(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.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 (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
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", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
// ── Status ──
if (dom.status) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function load(): Promise<void> {
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error: unknown) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Page lifecycle ──────────────────────────────────────────────────
function clearPredictionDom(): void {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
/**
* Called by the router each time the Satellites page is opened, including the
* visit that imports this module so loading is driven from one place only.
*/
predWindow.refreshSatPredictions = function () {
predShowAll = false;
void load();
};
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
export {};
import { isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage {
@@ -228,7 +229,9 @@ function updateVdesBar() {
updateVdesSummary();
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
const messages = vdesMessageHistory
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
.slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -22,9 +22,18 @@ const BEACON = {
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// A second rig listening in the background, on its own band. Its traffic
// belongs in the panels, which aggregate every rig, but not in the mini view,
// which captions the spectrum of the rig on screen.
const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
const fixture = await startWebFixture({
spectrum: true, decodes: [VESSEL, BEACON, OTHER_RIG_VESSEL], mode: "AIS",
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
@@ -41,11 +50,13 @@ try {
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
mapLoaded: !!window.trx.modules.map,
}));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
assert.equal(panels.otherRig, true, "the AIS panel dropped the background rig's vessel");
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
@@ -56,11 +67,13 @@ try {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
otherRig: bar.textContent.includes("ELDERBERRY"),
};
});
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel");
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
// Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not
@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The mini views over the waterfall caption the spectrum below them, so they
// show only what the rig on screen heard. The panels on the decoder tabs stay
// aggregate — every rig's traffic lands there — which is the distinction these
// tests pin down: a decode from a background rig belongs in the list and not in
// the overlay.
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
class ElementFixture {
constructor(value = "") {
this.children = [];
this.innerHTML = "";
this.textContent = "";
this.value = value;
this.style = {};
this.dataset = {};
this.classList = { add() {}, remove() {}, toggle() {} };
}
appendChild(child) { this.children.push(child); return child; }
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
addEventListener() {}
setAttribute() {}
querySelector() { return null; }
querySelectorAll() { return []; }
get firstChild() { return this.children[0] ?? null; }
get lastElementChild() { return this.children.at(-1) ?? null; }
get scrollHeight() { return this.children.length; }
}
/** A document whose named elements exist and whose unknown ones do not. */
function createDocument(elements) {
return {
documentElement: {},
getElementById: (id) => elements.get(id) ?? null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createDocumentFragment: () => new ElementFixture(),
};
}
async function runPlugin(entry, { window, document: doc, extras = {} }) {
const context = vm.createContext({
window,
document: doc,
navigator: {},
requestAnimationFrame(callback) { callback(); return 1; },
getComputedStyle: () => ({ getPropertyValue: () => "" }),
Date, Number, String, Math, Array, Map, Set, Reflect, console,
...extras,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
return context;
}
test("the APRS mini view keeps to the active rig while the panel lists both", async () => {
const overlay = new ElementFixture();
const packets = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["aprs-packets", packets],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
assert.equal(packets.children.length, 2, "the APRS panel dropped a frame it should still list");
});
test("the APRS mini view shows every frame until a rig is known", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["mode", new ElementFixture("PKT")],
]);
const window = {
...createHost(),
trxUi: { confirm: async () => true },
aprsMapAddStation: () => {},
};
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.match(overlay.innerHTML, /SP2BBB/);
});
test("the AIS mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ais-bar-overlay", overlay],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /NEARBY/);
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
});
test("the VDES mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["vdes-bar-overlay", overlay],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
});
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["cw-bar-overlay", overlay],
["cw-output", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
});
test("the FT8 mini view keeps to the active rig", async () => {
const overlay = new ElementFixture();
const elements = new Map([
["ft8-bar-overlay", overlay],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
});
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Pass predictions used to be a third view inside the weather-satellite
// decoder, under Digital modes — a planning tool filed with the decoders it has
// nothing to do with. It is its own page now, so what this guards is that the
// page exists at its own address, renders passes, and that the decoder card no
// longer offers the view it gave up.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, window, getComputedStyle */
const HOUR = 3_600_000;
const now = Date.now();
const satPasses = {
satellite_count: 2,
passes: [
// In progress right now, so the page has a countdown to run.
{
satellite: "NOAA 19", category: "weather",
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
max_elevation_deg: 62.4, duration_s: 600,
azimuth_aos_deg: 10, azimuth_los_deg: 190,
},
{
satellite: "ISS", category: "amateur",
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
max_elevation_deg: 18.2, duration_s: 480,
azimuth_aos_deg: 200, azimuth_los_deg: 20,
},
],
};
const fixture = await startWebFixture({ satPasses });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1500, height: 950 });
// The page is reachable at its own address, not through a decoder sub-view.
// #content is the radio panel on the main tab, so the readiness signal for a
// deep link is the destination panel itself.
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-satellites").waitFor({ state: "visible" });
await page.waitForFunction(
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
null,
{ timeout: 5000 },
);
const rendered = await page.evaluate(() => ({
current: document.getElementById("sat-pred-current-list").textContent,
upcoming: document.getElementById("sat-pred-list").textContent,
status: document.getElementById("sat-pred-status").textContent,
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
}));
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
// A pass in progress counts down, so the seconds have to move on their own.
const firstTick = await page.evaluate(
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
await page.waitForTimeout(1600);
const secondTick = await page.evaluate(
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
// Leaving stops the countdown rather than leaving a timer running behind a
// hidden page.
await page.evaluate(() => { window.navigateToTab("main"); });
await page.waitForTimeout(300);
const afterLeaving = await page.evaluate(() => ({
visible: document.getElementById("tab-satellites").style.display,
rows: document.getElementById("sat-pred-current-list").childElementCount,
}));
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
// Coming back reloads rather than showing whatever was there before.
await page.evaluate(() => { window.navigateToTab("satellites"); });
await page.waitForFunction(
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
null,
{ timeout: 5000 },
);
// The page is an occasional destination, so it lives behind Tools rather than
// taking a slot in the operating strip — the same treatment Statistics,
// Recorder, Settings and About get.
const nav = await page.evaluate(() => ({
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
}));
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
// The decoder card keeps Live and History, and no longer offers Predictions.
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
const satCard = await page.evaluate(() => ({
predictionsButton: !!document.getElementById("sat-view-predictions"),
predictionsView: !!document.getElementById("sat-predictions-view"),
live: !!document.getElementById("sat-view-live"),
history: !!document.getElementById("sat-view-history"),
}));
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
console.log("satellite predictions page tests passed");
} finally {
await browser.close();
await fixture.close();
}
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
path: window.location.pathname,
}));
// Typed, not filled. The app holds back its own refreshes of the frequency
// field from the first keystroke until Enter, so that a state update arriving
// mid-edit does not rewrite what is being typed. `fill()` sets the value
// without a keystroke, leaving the field unguarded: on a slow machine a state
// update could land between the fill and the Enter and put the old frequency
// back, and the Enter would then re-apply the frequency the radio was already
// on. Selecting first arms the guard before a single character changes.
async function tuneByHand(text) {
const field = page.locator("#freq");
await field.click();
await field.press("ControlOrMeta+a");
await field.pressSequentially(text);
await field.press("Enter");
}
try {
await page.setViewportSize({ width: 1500, height: 950 });
@@ -49,8 +64,7 @@ try {
// Tuning by hand rewrites the link. This is the part that makes the address
// bar shareable at any moment rather than only at load.
await page.locator("#freq").fill("7.040M");
await page.locator("#freq").press("Enter");
await tuneByHand("7.040M");
await page.waitForTimeout(1500);
const tuned = await dial();
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
@@ -59,8 +73,7 @@ try {
// Tuning is not navigation: a swept dial must not bury the back button.
const historyLength = await page.evaluate(() => window.history.length);
await page.locator("#freq").fill("7.100M");
await page.locator("#freq").press("Enter");
await tuneByHand("7.100M");
await page.waitForTimeout(1200);
assert.equal(await page.evaluate(() => window.history.length), historyLength,
"tuning pushed a history entry instead of replacing one");
@@ -135,6 +135,7 @@ export async function startWebFixture({
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -230,6 +231,7 @@ export async function startWebFixture({
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
@@ -101,6 +101,12 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
@@ -639,6 +639,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::satellites_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
+76
View File
@@ -76,6 +76,8 @@ pub struct RemoteConfig {
pub auth: RemoteAuthConfig,
/// Poll interval in milliseconds.
pub poll_interval_ms: u64,
/// Spectrum frame interval in milliseconds.
pub spectrum_interval_ms: u64,
}
impl Default for RemoteConfig {
@@ -85,6 +87,7 @@ impl Default for RemoteConfig {
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: default_spectrum_interval_ms(),
}
}
}
@@ -121,12 +124,23 @@ pub struct RemoteEntry {
/// Poll interval in milliseconds. Defaults to 750.
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64,
/// How often spectrum frames are wanted from this server, in milliseconds.
/// Defaults to 50 (20 frames/s).
///
/// Raise it on a slow or high-latency link: spectrum is by far the largest
/// thing on the connection, and the server pushes no faster than this.
#[serde(default = "default_spectrum_interval_ms")]
pub spectrum_interval_ms: u64,
}
fn default_poll_interval_ms() -> u64 {
750
}
fn default_spectrum_interval_ms() -> u64 {
50
}
/// Frontend configurations.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -421,6 +435,7 @@ impl ClientConfig {
rig_id: self.remote.rig_id.clone(),
auth: self.remote.auth.clone(),
poll_interval_ms: self.remote.poll_interval_ms,
spectrum_interval_ms: self.remote.spectrum_interval_ms,
}]
} else {
Vec::new()
@@ -603,12 +618,21 @@ impl ClientConfig {
i, entry.name
));
}
if entry.spectrum_interval_ms == 0 {
return Err(format!(
"[[remotes]][{}].spectrum_interval_ms must be > 0 (name \"{}\")",
i, entry.name
));
}
}
// Legacy [remote], kept for backward compatibility.
if self.remote.poll_interval_ms == 0 {
return Err("[remote].poll_interval_ms must be > 0".to_string());
}
if self.remote.spectrum_interval_ms == 0 {
return Err("[remote].spectrum_interval_ms must be > 0".to_string());
}
if let Some(url) = &self.remote.url {
if url.trim().is_empty() {
return Err("[remote].url must not be empty when set".to_string());
@@ -834,6 +858,7 @@ impl ClientConfig {
token_file: None,
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
RemoteEntry {
name: "home-vhf".to_string(),
@@ -844,6 +869,7 @@ impl ClientConfig {
token_file: None,
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
],
frontends: FrontendsConfig {
@@ -994,6 +1020,7 @@ impl ConfigFile for ClientConfig {
rig_id: Some("hf".to_string()),
auth: RemoteAuthConfig::default(),
poll_interval_ms: default_poll_interval_ms(),
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1311,6 +1338,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1330,6 +1358,7 @@ url = "remote.example.com:4530"
token_file: None,
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
..Default::default()
};
@@ -1368,6 +1397,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1386,6 +1416,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
RemoteEntry {
name: "dup".to_string(),
@@ -1393,6 +1424,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
],
..Default::default()
@@ -1409,6 +1441,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1427,6 +1460,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1445,6 +1479,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 0,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1499,6 +1534,45 @@ url = "remote.example.com:4530"
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
// --- Spectrum cadence ---
#[test]
fn test_spectrum_interval_defaults_and_parses() {
let config: ClientConfig = toml::from_str(
r#"
[[remotes]]
name = "slow-link"
url = "host:4530"
spectrum_interval_ms = 500
[[remotes]]
name = "lan"
url = "host2:4530"
"#,
)
.unwrap();
assert_eq!(config.remotes[0].spectrum_interval_ms, 500);
assert_eq!(config.remotes[1].spectrum_interval_ms, 50);
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_zero_spectrum_interval() {
let config: ClientConfig = toml::from_str(
r#"
[[remotes]]
name = "hf"
url = "host:4530"
spectrum_interval_ms = 0
"#,
)
.unwrap();
assert!(config
.validate()
.unwrap_err()
.contains("spectrum_interval_ms"));
}
// --- Secret indirection ---
fn secret_file(content: &str) -> tempfile::NamedTempFile {
@@ -1537,6 +1611,7 @@ url = "remote.example.com:4530"
token_file: Some(f.path().to_str().unwrap().to_string()),
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1570,6 +1645,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
})
.collect()
}
+130
View File
@@ -399,12 +399,30 @@ pub struct SdrConfig {
/// Default: 4.
#[serde(default = "default_max_virtual_channels")]
pub max_virtual_channels: usize,
/// FFT bin count for the spectrum display. Must be a power of two.
///
/// Halving it halves both the DSP cost and the bytes each frame puts on
/// the network, at half the frequency resolution — worth it on a slow link.
#[serde(default = "default_spectrum_fft_size")]
pub spectrum_fft_size: usize,
/// How often the server pushes a spectrum frame to a subscribed client, in
/// milliseconds. Raise it on a slow or metered link.
#[serde(default = "default_spectrum_interval_ms")]
pub spectrum_interval_ms: u64,
}
fn default_max_virtual_channels() -> usize {
4
}
fn default_spectrum_fft_size() -> usize {
1024
}
fn default_spectrum_interval_ms() -> u64 {
50
}
impl Default for SdrConfig {
fn default() -> Self {
Self {
@@ -417,6 +435,8 @@ impl Default for SdrConfig {
noise_blanker: SdrNoiseBlankerConfig::default(),
channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(),
spectrum_interval_ms: default_spectrum_interval_ms(),
}
}
}
@@ -913,6 +933,9 @@ fn validate_rig_instance(
errors.push(format!("{prefix}[sdr.gain].max_value must be >= 0"));
}
}
if let Err(e) = validate_spectrum_config(prefix, &rig.sdr) {
errors.push(e);
}
if let Err(e) = validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch)
{
errors.push(e);
@@ -1070,6 +1093,26 @@ fn validate_access(prefix: &str, access: &AccessConfig) -> Result<(), String> {
Ok(())
}
/// A non-power-of-two FFT size still plans, but costs far more per frame; the
/// bounds keep the display useful without letting a typo allocate a huge FFT.
fn validate_spectrum_config(prefix: &str, sdr: &SdrConfig) -> Result<(), String> {
let size = sdr.spectrum_fft_size;
if !(128..=8192).contains(&size) {
return Err(format!(
"{prefix}[sdr].spectrum_fft_size must be in range 128..=8192"
));
}
if !size.is_power_of_two() {
return Err(format!(
"{prefix}[sdr].spectrum_fft_size must be a power of two (got {size})"
));
}
if sdr.spectrum_interval_ms == 0 {
return Err(format!("{prefix}[sdr].spectrum_interval_ms must be > 0"));
}
Ok(())
}
fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> {
if !squelch.threshold_db.is_finite() {
return Err(format!("{path}.threshold_db must be finite"));
@@ -1813,6 +1856,93 @@ port = 4531
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
// --- Spectrum transport knobs ---
#[test]
fn test_spectrum_defaults() {
let cfg = SdrConfig::default();
assert_eq!(cfg.spectrum_fft_size, 1024);
assert_eq!(cfg.spectrum_interval_ms, 50);
}
fn sdr_cfg_with_spectrum(fft_size: usize, interval_ms: u64) -> ServerConfig {
let mut cfg = ServerConfig::default();
cfg.rig.access.port = Some("/dev/ttyUSB0".to_string());
cfg.rig.access.baud = Some(9600);
cfg.sdr.spectrum_fft_size = fft_size;
cfg.sdr.spectrum_interval_ms = interval_ms;
cfg
}
#[test]
fn test_validate_accepts_smaller_power_of_two_fft() {
assert!(sdr_cfg_with_spectrum(256, 200).validate().is_ok());
}
#[test]
fn test_validate_rejects_non_power_of_two_fft() {
let err = sdr_cfg_with_spectrum(1000, 50)
.validate()
.expect_err("expected a power-of-two error");
assert!(err.contains("power of two"), "unexpected error: {err}");
}
#[test]
fn test_validate_rejects_out_of_range_fft() {
assert!(sdr_cfg_with_spectrum(64, 50).validate().is_err());
assert!(sdr_cfg_with_spectrum(16384, 50).validate().is_err());
}
#[test]
fn test_validate_rejects_zero_spectrum_interval() {
let err = sdr_cfg_with_spectrum(1024, 0)
.validate()
.expect_err("expected an interval error");
assert!(
err.contains("spectrum_interval_ms"),
"unexpected error: {err}"
);
}
/// Per-rig validation covers the spectrum knobs too, and names the rig.
#[test]
fn test_validate_rejects_bad_spectrum_size_in_rig_entry() {
let toml_str = r#"
[[rigs]]
id = "sdr"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
[rigs.audio]
port = 4532
tx_enabled = false
[rigs.sdr]
spectrum_fft_size = 999
"#;
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
let err = cfg.validate().expect_err("expected a power-of-two error");
assert!(
err.contains("power of two") && err.contains("sdr"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_spectrum_config_from_toml() {
let cfg: ServerConfig = toml::from_str(
r#"
[sdr]
spectrum_fft_size = 512
spectrum_interval_ms = 250
"#,
)
.unwrap();
assert_eq!(cfg.sdr.spectrum_fft_size, 512);
assert_eq!(cfg.sdr.spectrum_interval_ms, 250);
}
// --- Secret indirection ---
#[test]
+1
View File
@@ -17,3 +17,4 @@ uuid = { workspace = true }
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
sgp4 = "2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
base64 = "0.22"
+1
View File
@@ -19,6 +19,7 @@ pub mod command;
pub mod controller;
pub mod request;
pub mod response;
pub mod spectrum_wire;
pub mod state;
/// How this backend communicates with the rig.
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Compact wire encoding for spectrum bins.
//!
//! Bins are dBFS magnitudes, and the web UI has always drawn them from `i8`
//! values — the SSE hop to the browser quantizes and base64-encodes them. The
//! server→client hop, which is the one that crosses the operator's network,
//! used to send the same information as a JSON array of `f32`: around ten bytes
//! per bin instead of one, or roughly 10 KB per 1024-bin frame.
//!
//! Bins therefore travel as base64-encoded `i8` dBFS, about an eighth of the
//! size, at a resolution the display already rounds to. Decoding still accepts
//! the old array form, so a new client can read an older server.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::de::{SeqAccess, Visitor};
use serde::{Deserializer, Serializer};
use std::fmt;
/// Quantize to whole dBFS and encode as base64.
pub fn serialize<S: Serializer>(bins: &[f32], serializer: S) -> Result<S::Ok, S::Error> {
let quantized: Vec<u8> = bins
.iter()
.map(|&db| {
let clamped = if db.is_finite() { db } else { -128.0 };
clamped.round().clamp(-128.0, 127.0) as i8 as u8
})
.collect();
serializer.serialize_str(&BASE64.encode(quantized))
}
/// Decode base64 bins, or a plain array of numbers from an older server.
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<f32>, D::Error> {
deserializer.deserialize_any(BinsVisitor)
}
struct BinsVisitor;
impl<'de> Visitor<'de> for BinsVisitor {
type Value = Vec<f32>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("base64-encoded i8 dBFS bins, or an array of numbers")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
let bytes = BASE64
.decode(value)
.map_err(|e| E::custom(format!("invalid base64 spectrum bins: {e}")))?;
Ok(bytes.into_iter().map(|byte| byte as i8 as f32).collect())
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut bins = Vec::with_capacity(seq.size_hint().unwrap_or(1024));
while let Some(value) = seq.next_element::<f32>()? {
bins.push(value);
}
Ok(bins)
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Frame {
#[serde(with = "super")]
bins: Vec<f32>,
}
#[test]
fn test_round_trip_quantizes_to_whole_db() {
let frame = Frame {
bins: vec![-73.4, -20.6, 0.0, -120.2],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-73.0, -21.0, 0.0, -120.0]);
}
#[test]
fn test_serializes_as_a_base64_string() {
let json = serde_json::to_string(&Frame {
bins: vec![-1.0, 0.0],
})
.unwrap();
assert!(json.contains('"'), "bins should be a string: {json}");
assert!(!json.contains('['), "bins should not be an array: {json}");
}
#[test]
fn test_clamps_out_of_range_and_non_finite() {
let frame = Frame {
bins: vec![-400.0, 400.0, f32::NAN, f32::NEG_INFINITY],
};
let json = serde_json::to_string(&frame).unwrap();
let back: Frame = serde_json::from_str(&json).unwrap();
assert_eq!(back.bins, vec![-128.0, 127.0, -128.0, -128.0]);
}
#[test]
fn test_reads_the_old_array_form() {
let back: Frame = serde_json::from_str(r#"{"bins":[-73.25,-20.5]}"#).unwrap();
assert_eq!(back.bins, vec![-73.25, -20.5]);
}
/// The point of the change: an ordinary frame gets much smaller.
#[test]
fn test_frame_is_far_smaller_than_the_array_form() {
let bins: Vec<f32> = (0..1024).map(|i| -60.0 - (i % 40) as f32 * 0.37).collect();
let compact = serde_json::to_string(&Frame { bins: bins.clone() }).unwrap();
let verbose = serde_json::to_string(&bins).unwrap();
assert!(
compact.len() * 5 < verbose.len(),
"compact {} bytes vs array {} bytes",
compact.len(),
verbose.len()
);
}
}
+6
View File
@@ -394,6 +394,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct SpectrumData {
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
///
/// On the wire these are base64-encoded `i8` whole dBFS (see
/// `spectrum_wire`), which is what the display draws anyway; the TypeScript
/// type describes the decoded array the browser receives over SSE.
#[serde(with = "crate::rig::spectrum_wire")]
#[ts(type = "Array<number>")]
pub bins: Vec<f32>,
/// Centre frequency of the SDR capture in Hz.
#[ts(type = "number")]
+3 -1
View File
@@ -18,4 +18,6 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
pub use types::{
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
};
+1 -1
View File
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
define_command_mapping! {
// ── Client-only variants (no RigCommand counterpart) ─────────────
client_only: GetRigs, GetSatPasses, SubscribeMeter;
client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
// ── Unit variants (no payload) ───────────────────────────────────
unit:
+22
View File
@@ -130,6 +130,28 @@ pub enum ClientCommand {
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
/// regular responses are sent. Intended for a dedicated TCP connection.
SubscribeMeter,
/// Subscribe to a per-rig spectrum stream on this connection. Like
/// `SubscribeMeter`, the connection becomes a one-way flow of
/// newline-delimited `SpectrumFrame` JSON and no further commands or
/// regular responses are sent.
///
/// Polling `GetSpectrum` costs a round trip per frame, which caps the frame
/// rate at 1/RTT no matter how often the client asks; the server pushes at
/// its own cadence instead. Clients fall back to polling when the server
/// rejects this command.
SubscribeSpectrum,
}
/// One spectrum frame pushed by the server on a dedicated spectrum stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpectrumFrame {
/// Rig identifier this frame belongs to.
pub rig_id: String,
/// The frame itself; bins travel base64-encoded (see `spectrum_wire`).
pub spectrum: trx_core::rig::state::SpectrumData,
/// Virtual-channel RDS state, mirroring what `GetSpectrum` returned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>,
}
/// Fast meter sample pushed by the server on a dedicated meter stream.
+117
View File
@@ -494,6 +494,58 @@ where
}
};
// SubscribeSpectrum: turns this connection into a one-way spectrum
// stream. Polling GetSpectrum costs a round trip per frame, so a
// client on a slow link could never reach the frame rate it asked for;
// pushing decouples the rate from the latency.
if matches!(envelope.cmd, ClientCommand::SubscribeSpectrum) {
let mut spectrum_rx = handle.spectrum_tx.subscribe();
let io_timeout = timeouts.io_timeout;
info!(
"Client {} subscribed to spectrum stream for rig '{}'",
addr, target_rig_id
);
loop {
tokio::select! {
frame = spectrum_rx.recv() => {
match frame {
Ok(frame) => {
let Ok(mut line) = serde_json::to_string(&frame) else { continue };
line.push('\n');
let write = time::timeout(
io_timeout,
writer.write_all(line.as_bytes()),
).await;
match write {
Ok(Ok(())) => {}
Ok(Err(e)) => {
info!("Client {} spectrum write failed: {}", addr, e);
break;
}
Err(_) => {
info!("Client {} spectrum write timed out", addr);
break;
}
}
}
// A client that cannot keep up skips to the newest
// frame; stale spectrum is not worth drawing.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => break,
Ok(()) => {}
Err(_) => break,
}
}
}
}
break;
}
// SubscribeMeter: turns this connection into a one-way meter stream.
// No regular responses are produced; the connection lives until the
// client disconnects or shutdown fires.
@@ -732,6 +784,7 @@ mod tests {
let (state_tx, state_rx) = watch::channel(state);
let _state_tx = state_tx;
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
let handle = RigHandle {
rig_id: "default".to_string(),
display_name: "Default Rig".to_string(),
@@ -739,6 +792,7 @@ mod tests {
state_rx,
audio_port: 4531,
meter_tx,
spectrum_tx,
};
let mut map = HashMap::new();
map.insert("default".to_string(), handle);
@@ -933,6 +987,7 @@ mod tests {
state_rx: state_rx_a,
audio_port: 4531,
meter_tx: meter_tx_a,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
@@ -945,6 +1000,7 @@ mod tests {
state_rx: state_rx_b,
audio_port: 4532,
meter_tx: meter_tx_b,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let mut map = HashMap::new();
@@ -953,6 +1009,67 @@ mod tests {
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
}
/// Polling spectrum costs a round trip per frame, so the rate a client can
/// reach is capped by latency rather than by what it asked for. Subscribed
/// clients get frames pushed instead; this is that path end to end.
#[tokio::test]
async fn subscribe_spectrum_pushes_frames() {
use trx_core::rig::state::SpectrumData;
let (rigs, default_id) = make_rigs(sample_state());
let spectrum_tx = rigs.get("default").expect("rig").spectrum_tx.clone();
let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
writer
.write_all(br#"{"cmd":"subscribe_spectrum"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
// The subscription is registered asynchronously; publish until it takes.
let frame = trx_protocol::SpectrumFrame {
rig_id: "default".to_string(),
spectrum: SpectrumData {
bins: vec![-73.4, -20.6, 0.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = String::new();
for _ in 0..50 {
let _ = spectrum_tx.send(frame.clone());
tokio::select! {
read = reader.read_line(&mut line) => {
if read.expect("read") > 0 && !line.trim().is_empty() {
break;
}
line.clear();
}
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
}
let received: trx_protocol::SpectrumFrame =
serde_json::from_str(line.trim()).unwrap_or_else(|e| panic!("frame {line:?}: {e}"));
assert_eq!(received.rig_id, "default");
assert_eq!(received.spectrum.center_hz, 14_200_000);
// Bins survive the trip quantized to whole dBFS, which is the
// resolution the display draws at anyway.
assert_eq!(received.spectrum.bins, vec![-73.0, -21.0, 0.0]);
// And they travel as base64 rather than a JSON array of floats.
assert!(
!line.contains("-73"),
"bins should not be spelled out on the wire: {line}"
);
let _ = shutdown_tx.send(true);
let _ = handle.await;
}
#[tokio::test]
async fn multi_rig_state_isolation() {
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
+8
View File
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
let pcm_rx = sdr_rig.subscribe_pcm();
@@ -460,6 +461,7 @@ fn build_rig_task_config(
prebuilt_rig: None,
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
}
}
@@ -1215,6 +1217,9 @@ async fn main() -> DynResult<()> {
let (state_tx, state_rx) = watch::channel(initial_state);
let (meter_tx, _) =
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
let (spectrum_tx, _) = broadcast::channel::<trx_protocol::SpectrumFrame>(
rig_handle::SPECTRUM_BROADCAST_CAPACITY,
);
let mut task_config = build_rig_task_config(
rig_cfg,
@@ -1242,12 +1247,14 @@ async fn main() -> DynResult<()> {
let rig_shutdown_rx = shutdown_rx.clone();
let rig_id_supervisor = rig_cfg.id.clone();
let meter_tx_task = meter_tx.clone();
let spectrum_tx_task = spectrum_tx.clone();
task_handles.push(tokio::spawn(async move {
let result = rig_task::run_rig_task(
task_config,
rig_rx,
state_tx.clone(),
meter_tx_task,
spectrum_tx_task,
rig_shutdown_rx,
)
.await;
@@ -1294,6 +1301,7 @@ async fn main() -> DynResult<()> {
rig_handles.insert(
rig_cfg.id.clone(),
RigHandle {
spectrum_tx: spectrum_tx.clone(),
rig_id: rig_cfg.id.clone(),
display_name: rig_cfg.display_name().to_string(),
rig_tx,
+10 -1
View File
@@ -8,13 +8,18 @@ use tokio::sync::{broadcast, mpsc, watch};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
use trx_protocol::MeterUpdate;
use trx_protocol::{MeterUpdate, SpectrumFrame};
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
/// without forcing the producer to block or drop silently.
pub const METER_BROADCAST_CAPACITY: usize = 16;
/// Bounded broadcast capacity for the spectrum stream. Frames are large and
/// only the newest one is worth drawing, so the buffer stays shallow: a slow
/// client lags and skips rather than making the server hold stale frames.
pub const SPECTRUM_BROADCAST_CAPACITY: usize = 4;
/// A handle to a single running rig backend.
///
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
@@ -34,4 +39,8 @@ pub struct RigHandle {
/// ~67 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
/// the slower `state_rx` snapshot path.
pub meter_tx: broadcast::Sender<MeterUpdate>,
/// Per-rig spectrum frames published by `rig_task` while at least one
/// client is subscribed. Consumed by `SubscribeSpectrum` clients; the
/// producer skips the work entirely when nobody is listening.
pub spectrum_tx: broadcast::Sender<SpectrumFrame>,
}
+27 -1
View File
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
use trx_core::{DynResult, RigError, RigResult};
use trx_protocol::MeterUpdate;
use trx_protocol::{MeterUpdate, SpectrumFrame};
use crate::audio::DecoderHistories;
use crate::error::is_invalid_bcd_error;
@@ -64,6 +64,8 @@ pub struct RigTaskConfig {
pub command_exec_timeout: Duration,
/// Maximum time for a CAT poll refresh cycle.
pub poll_refresh_timeout: Duration,
/// How often to push a spectrum frame to subscribed clients, in ms.
pub spectrum_interval_ms: u64,
}
impl Default for RigTaskConfig {
@@ -94,6 +96,7 @@ impl Default for RigTaskConfig {
prebuilt_rig: None,
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
spectrum_interval_ms: 50,
}
}
}
@@ -115,6 +118,7 @@ pub async fn run_rig_task(
mut rx: mpsc::Receiver<RigRequest>,
state_tx: watch::Sender<RigState>,
meter_tx: broadcast::Sender<MeterUpdate>,
spectrum_tx: broadcast::Sender<SpectrumFrame>,
mut shutdown_rx: watch::Receiver<bool>,
) -> DynResult<()> {
let histories = config.histories.clone();
@@ -273,6 +277,13 @@ pub async fn run_rig_task(
} else {
Duration::from_millis(150)
};
// Spectrum frames get their own tick so a subscribed client never pays a
// round trip per frame. The FFT is computed by the SDR thread either way;
// this only reads the latest result, and only while somebody is subscribed.
let spectrum_tick_duration = Duration::from_millis(config.spectrum_interval_ms.max(1));
let mut spectrum_tick: std::pin::Pin<Box<tokio::time::Sleep>> =
Box::pin(tokio::time::sleep(spectrum_tick_duration));
let meter_task_start = Instant::now();
let meter_state_delta_db: f64 = 0.25;
let rig_id = config.rig_id.clone();
@@ -302,6 +313,21 @@ pub async fn run_rig_task(
Err(_) => break,
}
}
// Push the latest spectrum frame to subscribed clients.
_ = &mut spectrum_tick => {
spectrum_tick = Box::pin(tokio::time::sleep(spectrum_tick_duration));
// `send` fails only when nobody is listening, but building the
// frame clones a few KB of bins, so check before doing the work.
if spectrum_tx.receiver_count() > 0 {
if let Some(spectrum) = rig.as_sdr_ref().and_then(|s| s.get_spectrum()) {
let _ = spectrum_tx.send(SpectrumFrame {
rig_id: rig_id.clone(),
spectrum,
vchan_rds: rig.as_sdr_ref().and_then(|s| s.get_vchan_rds()),
});
}
}
}
// Fast meter-only refresh between full polls.
_ = &mut meter_tick => {
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
@@ -154,6 +154,7 @@ impl SdrPipeline {
squelch_cfg: VirtualSquelchConfig,
nb_cfg: NoiseBlankerConfig,
channels: &[(f64, RigMode, u32)],
spectrum_fft_size: usize,
) -> Self {
const IQ_BROADCAST_CAPACITY: usize = 64;
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -219,6 +220,7 @@ impl SdrPipeline {
.name("sdr-iq-read".to_string())
.spawn(move || {
iq_read_loop(
spectrum_fft_size,
source,
sdr_sample_rate,
thread_dsps,
@@ -317,6 +319,7 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
#[allow(clippy::too_many_arguments)]
fn iq_read_loop(
spectrum_fft_size: usize,
mut source: Box<dyn IqSource>,
sdr_sample_rate: u32,
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
@@ -335,7 +338,7 @@ fn iq_read_loop(
};
let throttle = !source.is_blocking();
let mut spectrum = SpectrumSnapshotter::new();
let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
let mut read_error_streak: u32 = 0;
let mut zero_read_streak: u32 = 0;
let mut overflow_log_window_start: Option<Instant> = None;
@@ -572,6 +575,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(200_000.0, RigMode::USB, 3000)],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 1);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
@@ -590,6 +594,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 0);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
@@ -9,28 +9,36 @@ use num_complex::Complex;
use rustfft::num_complex::Complex as FftComplex;
use rustfft::FftPlanner;
/// Number of FFT bins for the spectrum display.
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024;
/// Default number of FFT bins for the spectrum display, used when the config
/// does not say otherwise.
pub(super) const DEFAULT_SPECTRUM_FFT_SIZE: usize = 1024;
/// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block).
pub(super) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
pub(super) struct SpectrumSnapshotter {
fft_size: usize,
hann_window: Vec<f32>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
counter: usize,
}
impl SpectrumSnapshotter {
pub(super) fn new() -> Self {
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos()))
pub(super) fn new(fft_size: usize) -> Self {
let fft_size = if fft_size >= 2 {
fft_size
} else {
DEFAULT_SPECTRUM_FFT_SIZE
};
let hann_window: Vec<f32> = (0..fft_size)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (fft_size - 1) as f32).cos()))
.collect();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE);
let fft = planner.plan_fft_forward(fft_size);
Self {
fft_size,
hann_window,
fft,
counter: 0,
@@ -48,7 +56,7 @@ impl SpectrumSnapshotter {
}
self.counter = 0;
let take = samples.len().min(SPECTRUM_FFT_SIZE);
let take = samples.len().min(self.fft_size);
let mut buf: Vec<FftComplex<f32>> = samples[..take]
.iter()
.enumerate()
@@ -59,16 +67,15 @@ impl SpectrumSnapshotter {
)
})
.collect();
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0));
buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
self.fft.process(&mut buf);
let half = SPECTRUM_FFT_SIZE / 2;
let half = self.fft_size / 2;
let bins: Vec<f32> = buf[half..]
.iter()
.chain(buf[..half].iter())
.map(|value| {
let mag =
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
20.0 * mag.max(1e-10_f32).log10()
})
.collect();
@@ -73,6 +73,11 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
/// which is what a slow server↔client link cares about.
pub spectrum_fft_size: usize,
}
impl Default for SoapySdrConfig {
@@ -99,6 +104,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
spectrum_fft_size: 1024,
}
}
}
@@ -194,6 +200,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
args,
@@ -293,6 +300,7 @@ impl SoapySdrRig {
threshold: nb_threshold as f32,
},
&all_channels,
spectrum_fft_size,
));
let info = RigInfo {
@@ -418,6 +426,7 @@ impl SoapySdrRig {
nb_threshold: f64,
) -> DynResult<Self> {
Self::new_from_config(SoapySdrConfig {
spectrum_fft_size: 1024,
args: args.to_string(),
channels: channels.to_vec(),
gain_mode: gain_mode.to_string(),
@@ -439,6 +439,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(0.0, RigMode::USB, 3_000)],
1024,
))
}
+5
View File
@@ -120,6 +120,8 @@ wfm_deemphasis_us = 50
center_offset_hz = 100000
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain]
@@ -160,6 +162,7 @@ log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote]
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remote.auth]
@@ -168,6 +171,7 @@ name = "home-hf"
url = "192.168.1.100:4530"
rig_id = "hf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"
@@ -177,6 +181,7 @@ name = "home-vhf"
url = "192.168.1.100:4530"
rig_id = "vhf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"