Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52c4839c9 |
Generated
-1
@@ -3139,7 +3139,6 @@ dependencies = [
|
|||||||
name = "trx-core"
|
name = "trx-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
|
||||||
"flate2",
|
"flate2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -121,13 +121,6 @@ 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 |
|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
||||||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||||||
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
||||||
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 128–8192 |
|
|
||||||
| `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]`
|
#### `[sdr.gain]`
|
||||||
|
|
||||||
@@ -247,7 +240,6 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||||
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
|
||||||
|
|
||||||
#### `[remote.auth]`
|
#### `[remote.auth]`
|
||||||
|
|
||||||
@@ -295,41 +287,6 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||||||
loopback on Linux, BlackHole on macOS).
|
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
|
### CLI Override Summary
|
||||||
|
|
||||||
**trx-server:**
|
**trx-server:**
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ pub struct RemoteConfig {
|
|||||||
pub auth: RemoteAuthConfig,
|
pub auth: RemoteAuthConfig,
|
||||||
/// Poll interval in milliseconds.
|
/// Poll interval in milliseconds.
|
||||||
pub poll_interval_ms: u64,
|
pub poll_interval_ms: u64,
|
||||||
/// Spectrum frame interval in milliseconds.
|
|
||||||
pub spectrum_interval_ms: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RemoteConfig {
|
impl Default for RemoteConfig {
|
||||||
@@ -86,7 +84,6 @@ impl Default for RemoteConfig {
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: default_spectrum_interval_ms(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,23 +117,12 @@ pub struct RemoteEntry {
|
|||||||
/// Poll interval in milliseconds. Defaults to 750.
|
/// Poll interval in milliseconds. Defaults to 750.
|
||||||
#[serde(default = "default_poll_interval_ms")]
|
#[serde(default = "default_poll_interval_ms")]
|
||||||
pub poll_interval_ms: u64,
|
pub poll_interval_ms: u64,
|
||||||
/// How often spectrum frames are requested 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 {
|
fn default_poll_interval_ms() -> u64 {
|
||||||
750
|
750
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_spectrum_interval_ms() -> u64 {
|
|
||||||
50
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frontend configurations.
|
/// Frontend configurations.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -419,7 +405,6 @@ impl ClientConfig {
|
|||||||
rig_id: self.remote.rig_id.clone(),
|
rig_id: self.remote.rig_id.clone(),
|
||||||
auth: self.remote.auth.clone(),
|
auth: self.remote.auth.clone(),
|
||||||
poll_interval_ms: self.remote.poll_interval_ms,
|
poll_interval_ms: self.remote.poll_interval_ms,
|
||||||
spectrum_interval_ms: self.remote.spectrum_interval_ms,
|
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -467,12 +452,6 @@ impl ClientConfig {
|
|||||||
i, entry.name
|
i, entry.name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if entry.spectrum_interval_ms == 0 {
|
|
||||||
return Err(format!(
|
|
||||||
"[[remotes]][{}].spectrum_interval_ms must be > 0 (name \"{}\")",
|
|
||||||
i, entry.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,9 +459,6 @@ impl ClientConfig {
|
|||||||
if self.remote.poll_interval_ms == 0 {
|
if self.remote.poll_interval_ms == 0 {
|
||||||
return Err("[remote].poll_interval_ms must be > 0".to_string());
|
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 let Some(url) = &self.remote.url {
|
||||||
if url.trim().is_empty() {
|
if url.trim().is_empty() {
|
||||||
return Err("[remote].url must not be empty when set".to_string());
|
return Err("[remote].url must not be empty when set".to_string());
|
||||||
@@ -668,7 +644,6 @@ impl ClientConfig {
|
|||||||
token: Some("my-token".to_string()),
|
token: Some("my-token".to_string()),
|
||||||
},
|
},
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
},
|
},
|
||||||
RemoteEntry {
|
RemoteEntry {
|
||||||
name: "home-vhf".to_string(),
|
name: "home-vhf".to_string(),
|
||||||
@@ -678,7 +653,6 @@ impl ClientConfig {
|
|||||||
token: Some("my-token".to_string()),
|
token: Some("my-token".to_string()),
|
||||||
},
|
},
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
frontends: FrontendsConfig {
|
frontends: FrontendsConfig {
|
||||||
@@ -1070,7 +1044,6 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1089,7 +1062,6 @@ url = "remote.example.com:4530"
|
|||||||
token: Some("tok".to_string()),
|
token: Some("tok".to_string()),
|
||||||
},
|
},
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1128,7 +1100,6 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1147,7 +1118,6 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
},
|
},
|
||||||
RemoteEntry {
|
RemoteEntry {
|
||||||
name: "dup".to_string(),
|
name: "dup".to_string(),
|
||||||
@@ -1155,7 +1125,6 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1172,7 +1141,6 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1191,7 +1159,6 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -1201,43 +1168,6 @@ url = "remote.example.com:4530"
|
|||||||
.contains("url must not be empty"));
|
.contains("url must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_zero_remote_poll_interval() {
|
fn test_validate_rejects_zero_remote_poll_interval() {
|
||||||
let config = ClientConfig {
|
let config = ClientConfig {
|
||||||
@@ -1247,7 +1177,6 @@ spectrum_interval_ms = 0
|
|||||||
rig_id: None,
|
rig_id: None,
|
||||||
auth: RemoteAuthConfig::default(),
|
auth: RemoteAuthConfig::default(),
|
||||||
poll_interval_ms: 0,
|
poll_interval_ms: 0,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
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);
|
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||||
vec![RemoteEntry {
|
vec![RemoteEntry {
|
||||||
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
|
||||||
name,
|
name,
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
rig_id,
|
rig_id,
|
||||||
@@ -367,13 +366,6 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.map(|e| e.poll_interval_ms)
|
.map(|e| e.poll_interval_ms)
|
||||||
.min()
|
.min()
|
||||||
.unwrap_or(750);
|
.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);
|
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
@@ -387,8 +379,6 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_interval),
|
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(),
|
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use trx_core::{RigError, RigResult};
|
|||||||
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
||||||
use trx_protocol::rig_command_to_client;
|
use trx_protocol::rig_command_to_client;
|
||||||
use trx_protocol::types::RigEntry;
|
use trx_protocol::types::RigEntry;
|
||||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
|
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
||||||
|
|
||||||
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
||||||
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
||||||
@@ -46,10 +46,9 @@ impl RemoteEndpoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default spectrum cadence when a config does not specify one. Both the push
|
// Keep remote spectrum reasonably responsive without returning to the old
|
||||||
// stream and the poll fallback run at the configured rate; see
|
// timeout churn caused by a much tighter request cadence.
|
||||||
// `[[remotes]].spectrum_interval_ms`.
|
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RemoteClientConfig {
|
pub struct RemoteClientConfig {
|
||||||
@@ -58,12 +57,6 @@ pub struct RemoteClientConfig {
|
|||||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||||
pub poll_interval: Duration,
|
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.
|
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
||||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||||
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
||||||
@@ -512,100 +505,6 @@ 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(
|
async fn handle_spectrum_connection(
|
||||||
config: &RemoteClientConfig,
|
config: &RemoteClientConfig,
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
@@ -613,33 +512,7 @@ async fn handle_spectrum_connection(
|
|||||||
) -> RigResult<()> {
|
) -> RigResult<()> {
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
let mut reader = BufReader::new(reader);
|
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.
|
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||||
let cached_token = config.token.clone();
|
let cached_token = config.token.clone();
|
||||||
|
|
||||||
@@ -1408,7 +1281,6 @@ mod tests {
|
|||||||
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
||||||
use super::{
|
use super::{
|
||||||
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
||||||
DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -1603,8 +1475,6 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(100),
|
poll_interval: Duration::from_millis(100),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1650,8 +1520,6 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1667,130 +1535,6 @@ mod tests {
|
|||||||
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
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]
|
#[test]
|
||||||
fn build_envelope_translates_short_name_to_server_rig_id() {
|
fn build_envelope_translates_short_name_to_server_rig_id() {
|
||||||
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
@@ -1804,8 +1548,6 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1838,8 +1580,6 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1864,8 +1604,6 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1906,8 +1644,6 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: known_rigs.clone(),
|
known_rigs: known_rigs.clone(),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1981,8 +1717,6 @@ mod tests {
|
|||||||
selected_rig_id,
|
selected_rig_id,
|
||||||
known_rigs,
|
known_rigs,
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
|
||||||
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
|||||||
@@ -885,7 +885,7 @@ function elementById(id) {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
@@ -1529,6 +1529,7 @@ var TAB_ORDER = [
|
|||||||
"bookmarks",
|
"bookmarks",
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
|
"satellites",
|
||||||
"statistics",
|
"statistics",
|
||||||
"recorder",
|
"recorder",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1539,6 +1540,7 @@ var TAB_PATHS = {
|
|||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
|
satellites: "/satellites",
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
@@ -1756,6 +1758,7 @@ var pluginGroups = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"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"],
|
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"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
@@ -5844,6 +5847,7 @@ function _initMapWhenReady() {
|
|||||||
}
|
}
|
||||||
function navigateToTab(name, options = {}) {
|
function navigateToTab(name, options = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
|
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
@@ -5873,7 +5877,10 @@ function navigateToTab(name, options = {}) {
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
void loadPluginsForTab(name).catch((error) => {
|
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||||
|
void loadPluginsForTab(name).then(() => {
|
||||||
|
if (name === "satellites") window.refreshSatPredictions?.();
|
||||||
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
@@ -6320,9 +6327,6 @@ function _wireSubTabBar(bar) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
|
||||||
window.clearSatPredictionDom();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ var satDom = {
|
|||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -17,29 +16,12 @@ var satDom = {
|
|||||||
typeFilter: document.getElementById("sat-type-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
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")
|
|
||||||
};
|
};
|
||||||
var satImageHistory = [];
|
var satImageHistory = [];
|
||||||
var SAT_MAX_IMAGES = 100;
|
var SAT_MAX_IMAGES = 100;
|
||||||
var SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
var satPredShowAll = false;
|
|
||||||
var satFilterText = "";
|
var satFilterText = "";
|
||||||
var satActiveView = "live";
|
var satActiveView = "live";
|
||||||
var satPredData = [];
|
|
||||||
var satPredFilterText = "";
|
|
||||||
var satPredMinEl = 0;
|
|
||||||
var satPredCategory = "all";
|
|
||||||
var satPredSatCount = 0;
|
|
||||||
var satPredCountdownTimer = null;
|
|
||||||
function scheduleSatUi(key, job) {
|
function scheduleSatUi(key, job) {
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
|
|||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
function switchSatView(view) {
|
function switchSatView(view) {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "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.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
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 (view === "history") renderSatHistoryTable();
|
||||||
if (leavingPredictions) clearPredictionDom();
|
|
||||||
if (view === "history") {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
} else if (view === "predictions") {
|
|
||||||
satPredShowAll = false;
|
|
||||||
void loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
switchSatView("live");
|
switchSatView("live");
|
||||||
});
|
});
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
switchSatView("history");
|
switchSatView("history");
|
||||||
});
|
});
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => {
|
|
||||||
switchSatView("predictions");
|
|
||||||
});
|
|
||||||
var lastSatLrptOn = null;
|
var lastSatLrptOn = null;
|
||||||
satWindow.updateSatLiveState = function(update) {
|
satWindow.updateSatLiveState = function(update) {
|
||||||
if (!satDom.lrptState) return;
|
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) {
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
satWindow.enableMapSourceFilter("sat");
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
|||||||
+228
@@ -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();
|
||||||
|
};
|
||||||
@@ -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-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-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-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-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-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>
|
<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>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</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">
|
<button class="tab" data-tab="statistics">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||||
<span class="tab-label">Statistics</span>
|
<span class="tab-label">Statistics</span>
|
||||||
@@ -918,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="sat-view-bar">
|
<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-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-history" class="sat-view-btn" type="button">History</button>
|
||||||
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Live view -->
|
<!-- Live view -->
|
||||||
<div id="sat-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>
|
<div id="sat-history-list"></div>
|
||||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||||
</div>
|
</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…</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
@@ -1117,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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…</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Statistics</h2>
|
<h2 class="section-heading">Statistics</h2>
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
|
|||||||
@@ -3816,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
/* One navigation model at every width. Statistics, Recorder, Settings and
|
/* One navigation model at every width. Satellites, Statistics, Recorder,
|
||||||
* About are occasional destinations: they live behind More rather than
|
* Settings and About are occasional destinations: they live behind More rather
|
||||||
* competing with the operating tabs for the row and then scrolling out of
|
* 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
|
* 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. */
|
* 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="statistics"],
|
||||||
.tab-bar-nav .tab[data-tab="recorder"],
|
.tab-bar-nav .tab[data-tab="recorder"],
|
||||||
.tab-bar-nav .tab[data-tab="settings"],
|
.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);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||||
}
|
}
|
||||||
.tab-icon, .tab-more-icon { display: block; }
|
.tab-icon, .tab-more-icon { display: block; }
|
||||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
|
||||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
.tab[data-tab="recorder"], .tab[data-tab="settings"],
|
||||||
|
.tab[data-tab="about"] { display: none; }
|
||||||
.mobile-more-btn { display: flex; }
|
.mobile-more-btn { display: flex; }
|
||||||
.mobile-more-menu {
|
.mobile-more-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ await build({
|
|||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.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"),
|
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"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"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
|
|||||||
export type SpectrumData = {
|
export type SpectrumData = {
|
||||||
/**
|
/**
|
||||||
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
* 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>,
|
bins: Array<number>,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ declare global {
|
|||||||
refreshCwTonePicker?(): void;
|
refreshCwTonePicker?(): void;
|
||||||
updateFt8RfDisplay?(): void;
|
updateFt8RfDisplay?(): void;
|
||||||
clearSatPredictionDom?(): void;
|
clearSatPredictionDom?(): void;
|
||||||
|
refreshSatPredictions?(): void;
|
||||||
syncWefaxToggle?(enabled: boolean): void;
|
syncWefaxToggle?(enabled: boolean): void;
|
||||||
syncSstvToggle?(enabled: boolean): void;
|
syncSstvToggle?(enabled: boolean): void;
|
||||||
updateAisBar?(value?: number): void;
|
updateAisBar?(value?: number): void;
|
||||||
@@ -4849,6 +4850,7 @@ function _initMapWhenReady() {
|
|||||||
|
|
||||||
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
|
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
@@ -4889,7 +4891,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
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") {
|
if (name === "map") {
|
||||||
_initMapWhenReady();
|
_initMapWhenReady();
|
||||||
}
|
}
|
||||||
@@ -5241,10 +5249,6 @@ function _wireSubTabBar(bar: WiredElement) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
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);
|
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export const TAB_ORDER = [
|
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;
|
] as const;
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
|||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
|
satellites: "/satellites",
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// 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[]>> = {
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
// 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-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"],
|
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"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
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";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
type SatelliteView = "live" | "history" | "predictions";
|
type SatelliteView = "live" | "history";
|
||||||
interface SatelliteBridge {
|
interface SatelliteBridge {
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
clearSatPredictionDom?: () => void;
|
|
||||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||||
clearSatMapOverlays?: () => void;
|
clearSatMapOverlays?: () => void;
|
||||||
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
|||||||
// --- SAT Plugin ---
|
// --- SAT Plugin ---
|
||||||
// Live view: decoder state, latest image card
|
// Live view: decoder state, latest image card
|
||||||
// History view: filterable table of all decoded images
|
// 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) ───────────────────────────────────
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
const satDom = {
|
const satDom = {
|
||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -42,30 +41,13 @@ const satDom = {
|
|||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
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 ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let satImageHistory: SatelliteImage[] = [];
|
let satImageHistory: SatelliteImage[] = [];
|
||||||
const SAT_MAX_IMAGES = 100;
|
const SAT_MAX_IMAGES = 100;
|
||||||
const SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
let satPredShowAll = false;
|
|
||||||
let satFilterText = "";
|
let satFilterText = "";
|
||||||
let satActiveView: SatelliteView = "live";
|
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 ─────────────────────────────────────────────
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
function scheduleSatUi(key: string, job: () => void): void {
|
function scheduleSatUi(key: string, job: () => void): void {
|
||||||
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
|
|||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchSatView(view: SatelliteView): void {
|
function switchSatView(view: SatelliteView): void {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "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.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (view === "history") renderSatHistoryTable();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let lastSatLrptOn: boolean | null = null;
|
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 ──────────────
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
|||||||
+290
@@ -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();
|
||||||
|
};
|
||||||
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
|
|||||||
+122
@@ -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();
|
||||||
|
}
|
||||||
@@ -135,6 +135,7 @@ export async function startWebFixture({
|
|||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
|
satPasses = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||||
remote,
|
remote,
|
||||||
@@ -230,6 +231,7 @@ export async function startWebFixture({
|
|||||||
["/bandplan.json", bandplan],
|
["/bandplan.json", bandplan],
|
||||||
["/api/recorder/status", []],
|
["/api/recorder/status", []],
|
||||||
["/api/recorder/files", []],
|
["/api/recorder/files", []],
|
||||||
|
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
// 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)
|
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")]
|
#[get("/bookmarks")]
|
||||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(assets::recorder_index)
|
.service(assets::recorder_index)
|
||||||
.service(assets::settings_index)
|
.service(assets::settings_index)
|
||||||
.service(assets::about_index)
|
.service(assets::about_index)
|
||||||
|
.service(assets::satellites_index)
|
||||||
.service(assets::statistics_index)
|
.service(assets::statistics_index)
|
||||||
.service(assets::bookmarks_index)
|
.service(assets::bookmarks_index)
|
||||||
.service(assets::favicon)
|
.service(assets::favicon)
|
||||||
|
|||||||
@@ -17,4 +17,3 @@ uuid = { workspace = true }
|
|||||||
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
|
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
|
||||||
sgp4 = "2"
|
sgp4 = "2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
base64 = "0.22"
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ pub mod command;
|
|||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod spectrum_wire;
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
/// How this backend communicates with the rig.
|
/// How this backend communicates with the rig.
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
// 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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -394,12 +394,6 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||||
pub struct SpectrumData {
|
pub struct SpectrumData {
|
||||||
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
/// 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>,
|
pub bins: Vec<f32>,
|
||||||
/// Centre frequency of the SDR capture in Hz.
|
/// Centre frequency of the SDR capture in Hz.
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
|
|||||||
@@ -18,6 +18,4 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
|
|||||||
pub use codec::{mode_to_string, parse_envelope, parse_mode};
|
pub use codec::{mode_to_string, parse_envelope, parse_mode};
|
||||||
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
|
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
|
||||||
pub use mapping::{client_command_to_rig, rig_command_to_client};
|
pub use mapping::{client_command_to_rig, rig_command_to_client};
|
||||||
pub use types::{
|
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
|
||||||
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
|
|||||||
|
|
||||||
define_command_mapping! {
|
define_command_mapping! {
|
||||||
// ── Client-only variants (no RigCommand counterpart) ─────────────
|
// ── Client-only variants (no RigCommand counterpart) ─────────────
|
||||||
client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
|
client_only: GetRigs, GetSatPasses, SubscribeMeter;
|
||||||
|
|
||||||
// ── Unit variants (no payload) ───────────────────────────────────
|
// ── Unit variants (no payload) ───────────────────────────────────
|
||||||
unit:
|
unit:
|
||||||
|
|||||||
@@ -130,28 +130,6 @@ pub enum ClientCommand {
|
|||||||
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
|
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
|
||||||
/// regular responses are sent. Intended for a dedicated TCP connection.
|
/// regular responses are sent. Intended for a dedicated TCP connection.
|
||||||
SubscribeMeter,
|
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.
|
/// Fast meter sample pushed by the server on a dedicated meter stream.
|
||||||
|
|||||||
@@ -339,30 +339,12 @@ pub struct SdrConfig {
|
|||||||
/// Default: 4.
|
/// Default: 4.
|
||||||
#[serde(default = "default_max_virtual_channels")]
|
#[serde(default = "default_max_virtual_channels")]
|
||||||
pub max_virtual_channels: usize,
|
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 {
|
fn default_max_virtual_channels() -> usize {
|
||||||
4
|
4
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_spectrum_fft_size() -> usize {
|
|
||||||
1024
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_spectrum_interval_ms() -> u64 {
|
|
||||||
50
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SdrConfig {
|
impl Default for SdrConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -375,8 +357,6 @@ impl Default for SdrConfig {
|
|||||||
noise_blanker: SdrNoiseBlankerConfig::default(),
|
noise_blanker: SdrNoiseBlankerConfig::default(),
|
||||||
channels: Vec::new(),
|
channels: Vec::new(),
|
||||||
max_virtual_channels: default_max_virtual_channels(),
|
max_virtual_channels: default_max_virtual_channels(),
|
||||||
spectrum_fft_size: default_spectrum_fft_size(),
|
|
||||||
spectrum_interval_ms: default_spectrum_interval_ms(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +562,6 @@ impl ServerConfig {
|
|||||||
return Err("[sdr.gain].max_value must be >= 0".to_string());
|
return Err("[sdr.gain].max_value must be >= 0".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
validate_spectrum_config(&self.sdr)?;
|
|
||||||
validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?;
|
validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?;
|
||||||
validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?;
|
validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?;
|
||||||
|
|
||||||
@@ -887,24 +866,6 @@ fn validate_access(access: &AccessConfig) -> Result<(), String> {
|
|||||||
Ok(())
|
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(sdr: &SdrConfig) -> Result<(), String> {
|
|
||||||
let size = sdr.spectrum_fft_size;
|
|
||||||
if !(128..=8192).contains(&size) {
|
|
||||||
return Err("[sdr].spectrum_fft_size must be in range 128..=8192".to_string());
|
|
||||||
}
|
|
||||||
if !size.is_power_of_two() {
|
|
||||||
return Err(format!(
|
|
||||||
"[sdr].spectrum_fft_size must be a power of two (got {size})"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if sdr.spectrum_interval_ms == 0 {
|
|
||||||
return Err("[sdr].spectrum_interval_ms must be > 0".to_string());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> {
|
fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> {
|
||||||
if !squelch.threshold_db.is_finite() {
|
if !squelch.threshold_db.is_finite() {
|
||||||
return Err(format!("{path}.threshold_db must be finite"));
|
return Err(format!("{path}.threshold_db must be finite"));
|
||||||
@@ -1279,68 +1240,6 @@ tokens = ["secret123"]
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_invalid_sdr_squelch_threshold() {
|
fn test_validate_rejects_invalid_sdr_squelch_threshold() {
|
||||||
let mut cfg = ServerConfig::default();
|
let mut cfg = ServerConfig::default();
|
||||||
|
|||||||
@@ -494,58 +494,6 @@ 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.
|
// SubscribeMeter: turns this connection into a one-way meter stream.
|
||||||
// No regular responses are produced; the connection lives until the
|
// No regular responses are produced; the connection lives until the
|
||||||
// client disconnects or shutdown fires.
|
// client disconnects or shutdown fires.
|
||||||
@@ -784,7 +732,6 @@ mod tests {
|
|||||||
let (state_tx, state_rx) = watch::channel(state);
|
let (state_tx, state_rx) = watch::channel(state);
|
||||||
let _state_tx = state_tx;
|
let _state_tx = state_tx;
|
||||||
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
|
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
|
||||||
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
|
|
||||||
let handle = RigHandle {
|
let handle = RigHandle {
|
||||||
rig_id: "default".to_string(),
|
rig_id: "default".to_string(),
|
||||||
display_name: "Default Rig".to_string(),
|
display_name: "Default Rig".to_string(),
|
||||||
@@ -792,7 +739,6 @@ mod tests {
|
|||||||
state_rx,
|
state_rx,
|
||||||
audio_port: 4531,
|
audio_port: 4531,
|
||||||
meter_tx,
|
meter_tx,
|
||||||
spectrum_tx,
|
|
||||||
};
|
};
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("default".to_string(), handle);
|
map.insert("default".to_string(), handle);
|
||||||
@@ -987,7 +933,6 @@ mod tests {
|
|||||||
state_rx: state_rx_a,
|
state_rx: state_rx_a,
|
||||||
audio_port: 4531,
|
audio_port: 4531,
|
||||||
meter_tx: meter_tx_a,
|
meter_tx: meter_tx_a,
|
||||||
spectrum_tx: tokio::sync::broadcast::channel(4).0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
|
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
|
||||||
@@ -1000,7 +945,6 @@ mod tests {
|
|||||||
state_rx: state_rx_b,
|
state_rx: state_rx_b,
|
||||||
audio_port: 4532,
|
audio_port: 4532,
|
||||||
meter_tx: meter_tx_b,
|
meter_tx: meter_tx_b,
|
||||||
spectrum_tx: tokio::sync::broadcast::channel(4).0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -1009,67 +953,6 @@ mod tests {
|
|||||||
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
|
(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]
|
#[tokio::test]
|
||||||
async fn multi_rig_state_isolation() {
|
async fn multi_rig_state_isolation() {
|
||||||
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
|
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
||||||
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
||||||
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
||||||
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let pcm_rx = sdr_rig.subscribe_pcm();
|
let pcm_rx = sdr_rig.subscribe_pcm();
|
||||||
@@ -453,7 +452,6 @@ fn build_rig_task_config(
|
|||||||
prebuilt_rig: None,
|
prebuilt_rig: None,
|
||||||
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
|
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
|
||||||
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
|
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
|
||||||
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,9 +1098,6 @@ async fn main() -> DynResult<()> {
|
|||||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||||
let (meter_tx, _) =
|
let (meter_tx, _) =
|
||||||
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
|
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(
|
let mut task_config = build_rig_task_config(
|
||||||
rig_cfg,
|
rig_cfg,
|
||||||
@@ -1130,14 +1125,12 @@ async fn main() -> DynResult<()> {
|
|||||||
let rig_shutdown_rx = shutdown_rx.clone();
|
let rig_shutdown_rx = shutdown_rx.clone();
|
||||||
let rig_id_supervisor = rig_cfg.id.clone();
|
let rig_id_supervisor = rig_cfg.id.clone();
|
||||||
let meter_tx_task = meter_tx.clone();
|
let meter_tx_task = meter_tx.clone();
|
||||||
let spectrum_tx_task = spectrum_tx.clone();
|
|
||||||
task_handles.push(tokio::spawn(async move {
|
task_handles.push(tokio::spawn(async move {
|
||||||
let result = rig_task::run_rig_task(
|
let result = rig_task::run_rig_task(
|
||||||
task_config,
|
task_config,
|
||||||
rig_rx,
|
rig_rx,
|
||||||
state_tx.clone(),
|
state_tx.clone(),
|
||||||
meter_tx_task,
|
meter_tx_task,
|
||||||
spectrum_tx_task,
|
|
||||||
rig_shutdown_rx,
|
rig_shutdown_rx,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1184,7 +1177,6 @@ async fn main() -> DynResult<()> {
|
|||||||
rig_handles.insert(
|
rig_handles.insert(
|
||||||
rig_cfg.id.clone(),
|
rig_cfg.id.clone(),
|
||||||
RigHandle {
|
RigHandle {
|
||||||
spectrum_tx: spectrum_tx.clone(),
|
|
||||||
rig_id: rig_cfg.id.clone(),
|
rig_id: rig_cfg.id.clone(),
|
||||||
display_name: rig_cfg.display_name().to_string(),
|
display_name: rig_cfg.display_name().to_string(),
|
||||||
rig_tx,
|
rig_tx,
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ use tokio::sync::{broadcast, mpsc, watch};
|
|||||||
|
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
use trx_protocol::{MeterUpdate, SpectrumFrame};
|
use trx_protocol::MeterUpdate;
|
||||||
|
|
||||||
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
|
/// 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
|
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
|
||||||
/// without forcing the producer to block or drop silently.
|
/// without forcing the producer to block or drop silently.
|
||||||
pub const METER_BROADCAST_CAPACITY: usize = 16;
|
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.
|
/// A handle to a single running rig backend.
|
||||||
///
|
///
|
||||||
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
|
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
|
||||||
@@ -39,8 +34,4 @@ pub struct RigHandle {
|
|||||||
/// ~6–7 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
|
/// ~6–7 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
|
||||||
/// the slower `state_rx` snapshot path.
|
/// the slower `state_rx` snapshot path.
|
||||||
pub meter_tx: broadcast::Sender<MeterUpdate>,
|
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>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
|
|||||||
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
|
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
|
||||||
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
|
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
|
||||||
use trx_core::{DynResult, RigError, RigResult};
|
use trx_core::{DynResult, RigError, RigResult};
|
||||||
use trx_protocol::{MeterUpdate, SpectrumFrame};
|
use trx_protocol::MeterUpdate;
|
||||||
|
|
||||||
use crate::audio::DecoderHistories;
|
use crate::audio::DecoderHistories;
|
||||||
use crate::error::is_invalid_bcd_error;
|
use crate::error::is_invalid_bcd_error;
|
||||||
@@ -64,8 +64,6 @@ pub struct RigTaskConfig {
|
|||||||
pub command_exec_timeout: Duration,
|
pub command_exec_timeout: Duration,
|
||||||
/// Maximum time for a CAT poll refresh cycle.
|
/// Maximum time for a CAT poll refresh cycle.
|
||||||
pub poll_refresh_timeout: Duration,
|
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 {
|
impl Default for RigTaskConfig {
|
||||||
@@ -96,7 +94,6 @@ impl Default for RigTaskConfig {
|
|||||||
prebuilt_rig: None,
|
prebuilt_rig: None,
|
||||||
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
|
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
|
||||||
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
|
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
|
||||||
spectrum_interval_ms: 50,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +115,6 @@ pub async fn run_rig_task(
|
|||||||
mut rx: mpsc::Receiver<RigRequest>,
|
mut rx: mpsc::Receiver<RigRequest>,
|
||||||
state_tx: watch::Sender<RigState>,
|
state_tx: watch::Sender<RigState>,
|
||||||
meter_tx: broadcast::Sender<MeterUpdate>,
|
meter_tx: broadcast::Sender<MeterUpdate>,
|
||||||
spectrum_tx: broadcast::Sender<SpectrumFrame>,
|
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> DynResult<()> {
|
) -> DynResult<()> {
|
||||||
let histories = config.histories.clone();
|
let histories = config.histories.clone();
|
||||||
@@ -277,13 +273,6 @@ pub async fn run_rig_task(
|
|||||||
} else {
|
} else {
|
||||||
Duration::from_millis(150)
|
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_task_start = Instant::now();
|
||||||
let meter_state_delta_db: f64 = 0.25;
|
let meter_state_delta_db: f64 = 0.25;
|
||||||
let rig_id = config.rig_id.clone();
|
let rig_id = config.rig_id.clone();
|
||||||
@@ -313,21 +302,6 @@ pub async fn run_rig_task(
|
|||||||
Err(_) => break,
|
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.
|
// Fast meter-only refresh between full polls.
|
||||||
_ = &mut meter_tick => {
|
_ = &mut meter_tick => {
|
||||||
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
|
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ impl SdrPipeline {
|
|||||||
squelch_cfg: VirtualSquelchConfig,
|
squelch_cfg: VirtualSquelchConfig,
|
||||||
nb_cfg: NoiseBlankerConfig,
|
nb_cfg: NoiseBlankerConfig,
|
||||||
channels: &[(f64, RigMode, u32)],
|
channels: &[(f64, RigMode, u32)],
|
||||||
spectrum_fft_size: usize,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||||
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
||||||
@@ -220,7 +219,6 @@ impl SdrPipeline {
|
|||||||
.name("sdr-iq-read".to_string())
|
.name("sdr-iq-read".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
iq_read_loop(
|
iq_read_loop(
|
||||||
spectrum_fft_size,
|
|
||||||
source,
|
source,
|
||||||
sdr_sample_rate,
|
sdr_sample_rate,
|
||||||
thread_dsps,
|
thread_dsps,
|
||||||
@@ -319,7 +317,6 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn iq_read_loop(
|
fn iq_read_loop(
|
||||||
spectrum_fft_size: usize,
|
|
||||||
mut source: Box<dyn IqSource>,
|
mut source: Box<dyn IqSource>,
|
||||||
sdr_sample_rate: u32,
|
sdr_sample_rate: u32,
|
||||||
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
|
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
|
||||||
@@ -338,7 +335,7 @@ fn iq_read_loop(
|
|||||||
};
|
};
|
||||||
let throttle = !source.is_blocking();
|
let throttle = !source.is_blocking();
|
||||||
|
|
||||||
let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
|
let mut spectrum = SpectrumSnapshotter::new();
|
||||||
let mut read_error_streak: u32 = 0;
|
let mut read_error_streak: u32 = 0;
|
||||||
let mut zero_read_streak: u32 = 0;
|
let mut zero_read_streak: u32 = 0;
|
||||||
let mut overflow_log_window_start: Option<Instant> = None;
|
let mut overflow_log_window_start: Option<Instant> = None;
|
||||||
@@ -575,7 +572,6 @@ mod tests {
|
|||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
NoiseBlankerConfig::default(),
|
NoiseBlankerConfig::default(),
|
||||||
&[(200_000.0, RigMode::USB, 3000)],
|
&[(200_000.0, RigMode::USB, 3000)],
|
||||||
1024,
|
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||||
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
|
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
|
||||||
@@ -594,7 +590,6 @@ mod tests {
|
|||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
NoiseBlankerConfig::default(),
|
NoiseBlankerConfig::default(),
|
||||||
&[],
|
&[],
|
||||||
1024,
|
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 0);
|
assert_eq!(pipeline.pcm_senders.len(), 0);
|
||||||
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
|
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
|
||||||
|
|||||||
@@ -9,36 +9,28 @@ use num_complex::Complex;
|
|||||||
use rustfft::num_complex::Complex as FftComplex;
|
use rustfft::num_complex::Complex as FftComplex;
|
||||||
use rustfft::FftPlanner;
|
use rustfft::FftPlanner;
|
||||||
|
|
||||||
/// Default number of FFT bins for the spectrum display, used when the config
|
/// Number of FFT bins for the spectrum display.
|
||||||
/// does not say otherwise.
|
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024;
|
||||||
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).
|
/// 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) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
|
||||||
|
|
||||||
pub(super) struct SpectrumSnapshotter {
|
pub(super) struct SpectrumSnapshotter {
|
||||||
fft_size: usize,
|
|
||||||
hann_window: Vec<f32>,
|
hann_window: Vec<f32>,
|
||||||
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
||||||
counter: usize,
|
counter: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpectrumSnapshotter {
|
impl SpectrumSnapshotter {
|
||||||
pub(super) fn new(fft_size: usize) -> Self {
|
pub(super) fn new() -> Self {
|
||||||
let fft_size = if fft_size >= 2 {
|
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE)
|
||||||
fft_size
|
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos()))
|
||||||
} 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();
|
.collect();
|
||||||
|
|
||||||
let mut planner = FftPlanner::<f32>::new();
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
let fft = planner.plan_fft_forward(fft_size);
|
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
fft_size,
|
|
||||||
hann_window,
|
hann_window,
|
||||||
fft,
|
fft,
|
||||||
counter: 0,
|
counter: 0,
|
||||||
@@ -56,7 +48,7 @@ impl SpectrumSnapshotter {
|
|||||||
}
|
}
|
||||||
self.counter = 0;
|
self.counter = 0;
|
||||||
|
|
||||||
let take = samples.len().min(self.fft_size);
|
let take = samples.len().min(SPECTRUM_FFT_SIZE);
|
||||||
let mut buf: Vec<FftComplex<f32>> = samples[..take]
|
let mut buf: Vec<FftComplex<f32>> = samples[..take]
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -67,15 +59,16 @@ impl SpectrumSnapshotter {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
|
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0));
|
||||||
self.fft.process(&mut buf);
|
self.fft.process(&mut buf);
|
||||||
|
|
||||||
let half = self.fft_size / 2;
|
let half = SPECTRUM_FFT_SIZE / 2;
|
||||||
let bins: Vec<f32> = buf[half..]
|
let bins: Vec<f32> = buf[half..]
|
||||||
.iter()
|
.iter()
|
||||||
.chain(buf[..half].iter())
|
.chain(buf[..half].iter())
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
|
let mag =
|
||||||
|
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
|
||||||
20.0 * mag.max(1e-10_f32).log10()
|
20.0 * mag.max(1e-10_f32).log10()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ pub struct SoapySdrConfig {
|
|||||||
pub nb_enabled: bool,
|
pub nb_enabled: bool,
|
||||||
/// Noise blanker impulse threshold multiplier.
|
/// Noise blanker impulse threshold multiplier.
|
||||||
pub nb_threshold: f64,
|
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 {
|
impl Default for SoapySdrConfig {
|
||||||
@@ -104,7 +99,6 @@ impl Default for SoapySdrConfig {
|
|||||||
max_virtual_channels: 4,
|
max_virtual_channels: 4,
|
||||||
nb_enabled: false,
|
nb_enabled: false,
|
||||||
nb_threshold: 10.0,
|
nb_threshold: 10.0,
|
||||||
spectrum_fft_size: 1024,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +194,6 @@ impl SoapySdrRig {
|
|||||||
let max_virtual_channels = config.max_virtual_channels;
|
let max_virtual_channels = config.max_virtual_channels;
|
||||||
let nb_enabled = config.nb_enabled;
|
let nb_enabled = config.nb_enabled;
|
||||||
let nb_threshold = config.nb_threshold;
|
let nb_threshold = config.nb_threshold;
|
||||||
let spectrum_fft_size = config.spectrum_fft_size;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
||||||
args,
|
args,
|
||||||
@@ -300,7 +293,6 @@ impl SoapySdrRig {
|
|||||||
threshold: nb_threshold as f32,
|
threshold: nb_threshold as f32,
|
||||||
},
|
},
|
||||||
&all_channels,
|
&all_channels,
|
||||||
spectrum_fft_size,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let info = RigInfo {
|
let info = RigInfo {
|
||||||
@@ -426,7 +418,6 @@ impl SoapySdrRig {
|
|||||||
nb_threshold: f64,
|
nb_threshold: f64,
|
||||||
) -> DynResult<Self> {
|
) -> DynResult<Self> {
|
||||||
Self::new_from_config(SoapySdrConfig {
|
Self::new_from_config(SoapySdrConfig {
|
||||||
spectrum_fft_size: 1024,
|
|
||||||
args: args.to_string(),
|
args: args.to_string(),
|
||||||
channels: channels.to_vec(),
|
channels: channels.to_vec(),
|
||||||
gain_mode: gain_mode.to_string(),
|
gain_mode: gain_mode.to_string(),
|
||||||
|
|||||||
@@ -439,7 +439,6 @@ mod tests {
|
|||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
NoiseBlankerConfig::default(),
|
NoiseBlankerConfig::default(),
|
||||||
&[(0.0, RigMode::USB, 3_000)],
|
&[(0.0, RigMode::USB, 3_000)],
|
||||||
1024,
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"
|
|||||||
|
|
||||||
[trx-server.sdr]
|
[trx-server.sdr]
|
||||||
sample_rate = 1920000
|
sample_rate = 1920000
|
||||||
spectrum_fft_size = 1024
|
|
||||||
spectrum_interval_ms = 50
|
|
||||||
bandwidth = 1500000
|
bandwidth = 1500000
|
||||||
wfm_deemphasis_us = 50
|
wfm_deemphasis_us = 50
|
||||||
center_offset_hz = 100000
|
center_offset_hz = 100000
|
||||||
@@ -92,7 +90,6 @@ log_level = "info"
|
|||||||
url = "192.168.1.100:9000"
|
url = "192.168.1.100:9000"
|
||||||
rig_id = "hf"
|
rig_id = "hf"
|
||||||
poll_interval_ms = 750
|
poll_interval_ms = 750
|
||||||
spectrum_interval_ms = 50
|
|
||||||
|
|
||||||
[trx-client.remote.auth]
|
[trx-client.remote.auth]
|
||||||
token = "my-token"
|
token = "my-token"
|
||||||
|
|||||||
Reference in New Issue
Block a user