Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e52c4839c9 | ||
|
|
77b283cb78 | ||
|
|
6284747339 | ||
|
|
14ad6e241b | ||
|
|
06971ff65c | ||
|
|
18107ce07e | ||
|
|
a0b0c0ed81 | ||
|
|
a2c630a92b | ||
|
|
09634eb851 | ||
|
|
90ab7781ad | ||
|
|
88d04253ca | ||
|
|
08005c5c07 | ||
|
|
026f816ddb | ||
|
|
d31b6f545f | ||
|
|
39f551c914 | ||
|
|
6d25ecdc11 | ||
|
|
37987b2779 | ||
|
|
67a7bace4e | ||
|
|
b48cc23d6e | ||
|
|
c1899229a0 | ||
|
|
84a99a3636 |
Generated
+11
@@ -3300,6 +3300,7 @@ dependencies = [
|
|||||||
"trx-ftx",
|
"trx-ftx",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"trx-reporting",
|
"trx-reporting",
|
||||||
|
"trx-sstv",
|
||||||
"trx-vdes",
|
"trx-vdes",
|
||||||
"trx-wefax",
|
"trx-wefax",
|
||||||
"trx-wspr",
|
"trx-wspr",
|
||||||
@@ -3307,6 +3308,16 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-sstv"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"png",
|
||||||
|
"tracing",
|
||||||
|
"trx-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-vdes"
|
name = "trx-vdes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ members = [
|
|||||||
"src/decoders/trx-ftx",
|
"src/decoders/trx-ftx",
|
||||||
"src/decoders/trx-rds",
|
"src/decoders/trx-rds",
|
||||||
"src/decoders/trx-vdes",
|
"src/decoders/trx-vdes",
|
||||||
|
"src/decoders/trx-sstv",
|
||||||
"src/decoders/trx-wefax",
|
"src/decoders/trx-wefax",
|
||||||
"src/decoders/trx-wspr",
|
"src/decoders/trx-wspr",
|
||||||
"src/trx-core",
|
"src/trx-core",
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Spectrum Controls — Visual Rework
|
||||||
|
|
||||||
|
The strip between the spectrum and the radio controls (`#spectrum-controls`)
|
||||||
|
holds eight controls in two groups. This proposes how it should *look*.
|
||||||
|
|
||||||
|
Every control stays, in its current order, with its current name and its
|
||||||
|
current behaviour. Nothing here changes what a button does, what commits when,
|
||||||
|
or what is stored. It is a styling and layout change.
|
||||||
|
|
||||||
|
*Status: implemented. Kept as the record of what was changed and why.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it looks like now
|
||||||
|
|
||||||
|
```
|
||||||
|
Bandwidth [ 12 ] kHz [Set] [Auto BW] [Sweet-spot] Peak Hold [2 s] Floor [-115] dB Range [90] dB [Auto] Contrast [——●——] 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Four problems, all of them visual:
|
||||||
|
|
||||||
|
**1. Four different control heights on one line.** The bare number inputs, the
|
||||||
|
buttons, the `select` and the range slider are each sized by their own rule, so
|
||||||
|
nothing shares a baseline and the row reads as a pile rather than a strip.
|
||||||
|
|
||||||
|
**2. Units are loose text.** `kHz`, `dB`, `dB` and the contrast value `1.0` are
|
||||||
|
text nodes sitting outside the control they belong to, separated from it by a
|
||||||
|
gap the same size as the gap between unrelated controls. The eye has to work
|
||||||
|
out which number owns which unit.
|
||||||
|
|
||||||
|
**3. A quarter of the strip is a hole.** `justify-content: space-between` puts
|
||||||
|
about 250 px of nothing in the middle at 1600 px, and the two groups read as
|
||||||
|
two unrelated things because the only thing between them is emptiness.
|
||||||
|
|
||||||
|
**4. The groups stagger between 1100 and 1400 px.** The bandwidth group wraps
|
||||||
|
to two lines while the level group stays on one, so the level group floats at a
|
||||||
|
height of its own, aligned with neither line of the group beside it. This is
|
||||||
|
the worst of it, and it happens at a common window width.
|
||||||
|
|
||||||
|
Two smaller things: 24 px controls are below any touch-target guideline, and
|
||||||
|
the contrast readout has no fixed width, so the row twitches as the value
|
||||||
|
changes between `1.0` and `0.9`.
|
||||||
|
|
||||||
|
## Proposed
|
||||||
|
|
||||||
|
**One control height, units inside their field, and a rule where the clusters
|
||||||
|
meet.**
|
||||||
|
|
||||||
|
- **A field is one box.** Label, value and unit share a single bordered box —
|
||||||
|
`Bandwidth │ 12.0 │ kHz` — so a number and its unit can never be read apart.
|
||||||
|
Fields, buttons, the peak-hold select and the contrast slider are all 1.7 rem
|
||||||
|
tall, on 44 px targets under a coarse pointer.
|
||||||
|
- **A rule, not a hole.** The two clusters are separated by a thin vertical
|
||||||
|
rule with normal spacing either side. The slack goes to a flexible spacer, so
|
||||||
|
the strip is left-aligned rather than pushed apart.
|
||||||
|
- **A cluster never splits.** Each cluster is `nowrap`; the container wraps. If
|
||||||
|
a cluster does not fit on the line it drops whole to the next one,
|
||||||
|
left-aligned with the one above. No staggering, at any width.
|
||||||
|
- **Rules fall away at line starts.** A cluster that begins a line has no rule
|
||||||
|
hanging off its left edge.
|
||||||
|
- **The contrast readout gets a fixed, tabular slot**, so the row is still.
|
||||||
|
|
||||||
|
Below the existing mobile breakpoint the strip already stacks; the same field
|
||||||
|
component applies there, which is most of what makes it look deliberate.
|
||||||
|
|
||||||
|
## What this does not change
|
||||||
|
|
||||||
|
`Set` stays. `Auto BW` and `Auto` keep their names, even though they mean
|
||||||
|
different things — that is a naming question, not a styling one. Sweet-spot
|
||||||
|
stays where it is and keeps its behaviour. Nothing gains or loses persistence.
|
||||||
|
Nothing moves into a popover, and no control is hidden behind a click.
|
||||||
|
|
||||||
|
Those are all worth arguing about separately; a note of them is at the end of
|
||||||
|
this file so the arguments are not lost.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
One pass, no behaviour touched:
|
||||||
|
|
||||||
|
1. `.spectrum-field` and `.spectrum-btn` in `style.css`, replacing the six
|
||||||
|
per-id rules (`#spectrum-bw-input`, `#spectrum-floor-input`,
|
||||||
|
`#spectrum-range-input`, `#spectrum-bw-label`, `#spectrum-floor-label`,
|
||||||
|
`#spectrum-range-label`) that currently repeat the same declarations.
|
||||||
|
2. Markup in `index.html`: the loose `kHz` / `dB` text nodes move inside their
|
||||||
|
label, which keeps every id and every event handler exactly where it is.
|
||||||
|
3. `#spectrum-controls` becomes a wrapping flex row with a spacer;
|
||||||
|
`#spectrum-bw-row` and `#spectrum-level-row` become `nowrap` clusters with a
|
||||||
|
left rule.
|
||||||
|
4. The mobile block in the media query drops the rules it no longer needs.
|
||||||
|
|
||||||
|
`app.ts` is not touched. Every id survives, so the existing handlers, the
|
||||||
|
`spectrum-layout.mjs` geometry test and the broadcast-layout highlight all keep
|
||||||
|
working.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Extend `spectrum-layout.mjs`, which already measures this area:
|
||||||
|
|
||||||
|
- Every control in the strip shares one height, at 1600, 1200 and 900 px.
|
||||||
|
- No two clusters sit at different vertical offsets on the same line — the
|
||||||
|
staggering bug, asserted directly.
|
||||||
|
- The strip never overflows its container and never overlaps the hint line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Noted for later, not proposed here
|
||||||
|
|
||||||
|
Behavioural observations from reading the code, kept so they are not lost:
|
||||||
|
|
||||||
|
- `Auto BW` (filter) and `Auto` (display scaling) are both called Auto, 600 px
|
||||||
|
apart.
|
||||||
|
- `Floor`, `Range` and `Contrast` are not persisted; `Peak Hold` is.
|
||||||
|
- `Auto` and `Auto BW` are one-shot: no state, nothing to turn off.
|
||||||
|
- `Sweet-spot` retunes the SDR and waits up to 1.4 s per candidate centre, with
|
||||||
|
no busy indication.
|
||||||
|
- Contrast resets on a double-click that nothing advertises.
|
||||||
@@ -300,6 +300,39 @@ loopback on Linux, BlackHole on macOS).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tune Links
|
||||||
|
|
||||||
|
Every page of the web UI carries what the radio is doing in its address, so the
|
||||||
|
URL in the address bar is always a link someone else can open:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
|
||||||
|
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
|
||||||
|
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
|
||||||
|
| `rig` | Rig to select first, by id, on a multi-rig client. |
|
||||||
|
|
||||||
|
Opening such a link selects the rig, sets the mode, tunes, and applies the
|
||||||
|
bandwidth, in that order — a mode change carries its own default bandwidth, so
|
||||||
|
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
|
||||||
|
a frequency outside its range) is reported and the rest of the link still
|
||||||
|
applies. All four parameters are optional.
|
||||||
|
|
||||||
|
The link button in the top bar copies the current link to the clipboard. The
|
||||||
|
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||||
|
the dial does not fill the browser's history.
|
||||||
|
|
||||||
|
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
||||||
|
session opens the page and says the link was not applied. Links describe the
|
||||||
|
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||||
|
left as it was, rather than publishing a frequency the rig is not on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
The HTTP frontend supports optional passphrase-based authentication with two
|
The HTTP frontend supports optional passphrase-based authentication with two
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-sstv"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
trx-core = { path = "../../trx-core" }
|
||||||
|
base64 = "0.22"
|
||||||
|
png = "0.17"
|
||||||
|
tracing = "0.1"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Encode a test card, decode it back, write both as PNGs to a directory.
|
||||||
|
//!
|
||||||
|
//! `cargo run -p trx-sstv --example round_trip_png -- /tmp/out`
|
||||||
|
|
||||||
|
use trx_sstv::encode::{encode, Frame};
|
||||||
|
use trx_sstv::mode::mode_for_vis;
|
||||||
|
use trx_sstv::{ImageCanvas, SstvConfig, SstvDecoder, SstvEvent};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
|
||||||
|
for vis in [44u8, 60, 12, 8, 95] {
|
||||||
|
let mode = mode_for_vis(vis).expect("mode in table");
|
||||||
|
let (w, h) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let mut rgb = vec![0u8; w * h * 3];
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let at = (y * w + x) * 3;
|
||||||
|
let (r, g, b) = if y < h / 3 {
|
||||||
|
[
|
||||||
|
(255u8, 255u8, 255u8),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
][x * 8 / w]
|
||||||
|
} else if y < 2 * h / 3 {
|
||||||
|
let t = (x * 255 / w) as u8;
|
||||||
|
(t, 255 - t, ((y * 255) / h) as u8)
|
||||||
|
} else {
|
||||||
|
// Diagonal stripes: a line-timing error shows up as a kink.
|
||||||
|
if ((x + y) / 16) % 2 == 0 {
|
||||||
|
(240, 240, 40)
|
||||||
|
} else {
|
||||||
|
(20, 20, 90)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rgb[at] = r;
|
||||||
|
rgb[at + 1] = g;
|
||||||
|
rgb[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = mode.name.replace(' ', "-");
|
||||||
|
let mut sent = ImageCanvas::new(w, h);
|
||||||
|
for y in 0..h {
|
||||||
|
sent.put_row(y, &rgb[y * w * 3..(y + 1) * w * 3]);
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
format!("{dir}/{name}-sent.png"),
|
||||||
|
sent.to_png().expect("png"),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
|
||||||
|
let frame = Frame {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
rgb: &rgb,
|
||||||
|
};
|
||||||
|
let mut audio = encode(mode, &frame, 48_000);
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, 4800));
|
||||||
|
let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||||
|
let mut got = None;
|
||||||
|
for block in audio.chunks(1024) {
|
||||||
|
for event in decoder.process_samples(block) {
|
||||||
|
if let SstvEvent::Complete(image) = event {
|
||||||
|
got = Some(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let image = got.expect("no image decoded");
|
||||||
|
let mut canvas = ImageCanvas::new(w, h);
|
||||||
|
for y in 0..h {
|
||||||
|
canvas.put_row(y, &image.rgb[y * w * 3..(y + 1) * w * 3]);
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
format!("{dir}/{name}-decoded.png"),
|
||||||
|
canvas.to_png().expect("png"),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
println!(
|
||||||
|
"{}: {} lines, complete={}",
|
||||||
|
mode.name, image.lines, image.complete
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! SSTV decoder configuration.
|
||||||
|
|
||||||
|
/// Settings for [`crate::decoder::SstvDecoder`].
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SstvConfig {
|
||||||
|
/// VIS code of a mode to assume when no header is heard, so tuning in
|
||||||
|
/// part-way through a transmission still produces a picture. `None` means
|
||||||
|
/// wait for a header, which is the safe default: guessing wrong yields a
|
||||||
|
/// convincing image of nothing.
|
||||||
|
pub force_mode: Option<u8>,
|
||||||
|
/// Directory for saved PNGs. `None` keeps images in memory only.
|
||||||
|
pub output_dir: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! The decoder: audio in, pictures out.
|
||||||
|
//!
|
||||||
|
//! Reception is a small state machine. It listens for a VIS header, and once
|
||||||
|
//! one names a mode it walks the transmission a line at a time, sampling each
|
||||||
|
//! scan at the offsets the mode's segment list gives. Every line is looked for
|
||||||
|
//! at the time the mode says it should arrive, then nudged into place by the
|
||||||
|
//! sync pulse actually found near it — a transmitter's clock and a receiver's
|
||||||
|
//! sound card never agree exactly, and over two minutes of Martin M1 an
|
||||||
|
//! uncorrected error of a few parts per million visibly shears the picture.
|
||||||
|
//!
|
||||||
|
//! Rows are emitted as they are decoded so a picture can be watched arriving,
|
||||||
|
//! which is most of the appeal of the mode.
|
||||||
|
|
||||||
|
use crate::config::SstvConfig;
|
||||||
|
use crate::demod::FreqDemod;
|
||||||
|
use crate::image::ImageCanvas;
|
||||||
|
use crate::mode::{level_from_hz, mode_for_vis, Channel, ColorModel, SstvMode};
|
||||||
|
use crate::vis::find_vis;
|
||||||
|
|
||||||
|
/// Anything below this is the sync pulse rather than picture: black is 1500 Hz
|
||||||
|
/// and sync is 1200 Hz, so the line sits between them.
|
||||||
|
const SYNC_THRESHOLD_HZ: f32 = 1350.0;
|
||||||
|
|
||||||
|
/// How much of the signal to keep while hunting for a header. A header is 940
|
||||||
|
/// ms; two seconds leaves room for one to straddle several blocks of audio.
|
||||||
|
const SEARCH_HISTORY_MS: f64 = 2000.0;
|
||||||
|
|
||||||
|
/// From the start bit to the end of the stop bit: ten 30 ms cells. A header
|
||||||
|
/// search must never retire a stretch shorter than this, or a start bit split
|
||||||
|
/// across two blocks of audio is dismissed on half a view of it.
|
||||||
|
const HEADER_SPAN_MS: f64 = 330.0;
|
||||||
|
|
||||||
|
/// How far from its predicted position a line's sync pulse is looked for.
|
||||||
|
const SYNC_SEARCH_MS: f64 = 12.0;
|
||||||
|
|
||||||
|
/// Fraction of the observed timing error applied to the next line. Damped,
|
||||||
|
/// because a sync pulse found in noise is worth less than the prediction.
|
||||||
|
const SYNC_CORRECTION: f64 = 0.45;
|
||||||
|
|
||||||
|
/// Consecutive lines with no sync pulse anywhere near the prediction before
|
||||||
|
/// the transmission is taken to have ended.
|
||||||
|
const MISSING_SYNC_LIMIT: u32 = 8;
|
||||||
|
|
||||||
|
/// What the decoder has to say.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SstvEvent {
|
||||||
|
/// A header was decoded and reception has begun.
|
||||||
|
Started {
|
||||||
|
vis: u8,
|
||||||
|
mode: &'static str,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
},
|
||||||
|
/// One image row is ready, as RGB triples.
|
||||||
|
Row { line: u16, rgb: Vec<u8> },
|
||||||
|
/// Reception finished — at the bottom of the frame, or because the signal
|
||||||
|
/// went away. Carries the picture either way.
|
||||||
|
Complete(SstvImage),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A received picture.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SstvImage {
|
||||||
|
pub vis: u8,
|
||||||
|
pub mode: &'static str,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Rows actually received, which is the height only if it ran to the end.
|
||||||
|
pub lines: u16,
|
||||||
|
/// Whether the whole frame arrived.
|
||||||
|
pub complete: bool,
|
||||||
|
/// RGB triples, `width * height * 3` bytes. Rows never received are grey.
|
||||||
|
pub rgb: Vec<u8>,
|
||||||
|
/// When reception started, in milliseconds since the epoch.
|
||||||
|
pub started_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SstvImage {
|
||||||
|
/// The picture as a canvas, for saving or encoding.
|
||||||
|
pub fn canvas(&self) -> ImageCanvas {
|
||||||
|
let width = usize::from(self.width);
|
||||||
|
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
|
||||||
|
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
|
||||||
|
canvas.put_row(y, row);
|
||||||
|
}
|
||||||
|
canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picture as PNG bytes.
|
||||||
|
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||||
|
self.canvas().to_png()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picture as a base64 PNG, for the journey to a client.
|
||||||
|
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||||
|
self.canvas().to_png_base64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the picture into `dir`, named for when and where it arrived.
|
||||||
|
pub fn save_png(
|
||||||
|
&self,
|
||||||
|
dir: &std::path::Path,
|
||||||
|
freq_hz: u64,
|
||||||
|
) -> Result<std::path::PathBuf, String> {
|
||||||
|
self.canvas()
|
||||||
|
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
|
||||||
|
fn stamp(ms: i64) -> String {
|
||||||
|
let secs = ms.div_euclid(1000);
|
||||||
|
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||||
|
let (year, month, day) = civil_from_days(days);
|
||||||
|
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
|
||||||
|
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
|
||||||
|
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z.rem_euclid(146_097);
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
/// Listening for a header.
|
||||||
|
Searching,
|
||||||
|
Receiving(Box<Reception>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reception {
|
||||||
|
mode: &'static SstvMode,
|
||||||
|
canvas: ImageCanvas,
|
||||||
|
/// Absolute sample index at which the next transmitted line begins.
|
||||||
|
next_line: f64,
|
||||||
|
/// Next image row to write.
|
||||||
|
row: u16,
|
||||||
|
started_ms: i64,
|
||||||
|
missing_syncs: u32,
|
||||||
|
/// Set until the first line has been decoded. A VIS header ends exactly
|
||||||
|
/// where the picture begins, so the first line is already aligned — and a
|
||||||
|
/// search would find the header's own 30 ms stop bit, which is at the sync
|
||||||
|
/// frequency and sits immediately before the picture.
|
||||||
|
first_line: bool,
|
||||||
|
/// Robot 36 sends one chroma channel per line and expects the decoder to
|
||||||
|
/// carry the other over from the line before.
|
||||||
|
last_chroma_r: Option<Vec<u8>>,
|
||||||
|
last_chroma_b: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SstvDecoder {
|
||||||
|
sample_rate: u32,
|
||||||
|
config: SstvConfig,
|
||||||
|
demod: FreqDemod,
|
||||||
|
/// Instantaneous frequency, one entry per audio sample. Delayed by the
|
||||||
|
/// demodulator's group delay, which is constant and so shifts the whole
|
||||||
|
/// stream — header and lines alike — without disturbing their spacing.
|
||||||
|
freqs: Vec<f32>,
|
||||||
|
/// Absolute index of `freqs[0]`, so positions survive the buffer being
|
||||||
|
/// trimmed.
|
||||||
|
base: u64,
|
||||||
|
/// Absolute index the header search has already covered.
|
||||||
|
searched_to: u64,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SstvDecoder {
|
||||||
|
pub fn new(sample_rate: u32, config: SstvConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate,
|
||||||
|
config,
|
||||||
|
demod: FreqDemod::new(sample_rate),
|
||||||
|
freqs: Vec::new(),
|
||||||
|
base: 0,
|
||||||
|
searched_to: 0,
|
||||||
|
state: State::Searching,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a picture is currently arriving.
|
||||||
|
pub fn is_receiving(&self) -> bool {
|
||||||
|
matches!(self.state, State::Receiving(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a block of mono audio. Returns whatever it produced.
|
||||||
|
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<SstvEvent> {
|
||||||
|
self.demod.process_into(samples, &mut self.freqs);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
loop {
|
||||||
|
let progressed = match self.state {
|
||||||
|
State::Searching => self.try_start(&mut events),
|
||||||
|
State::Receiving(_) => self.try_line(&mut events),
|
||||||
|
};
|
||||||
|
if !progressed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.trim();
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon a reception in progress, returning the picture so far.
|
||||||
|
pub fn reset(&mut self) -> Vec<SstvEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if let State::Receiving(reception) = std::mem::replace(&mut self.state, State::Searching) {
|
||||||
|
events.push(SstvEvent::Complete(finish(&reception)));
|
||||||
|
}
|
||||||
|
self.demod.reset();
|
||||||
|
self.freqs.clear();
|
||||||
|
self.base = 0;
|
||||||
|
self.searched_to = 0;
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look for a header, or for a bare sync pulse when the mode is forced.
|
||||||
|
fn try_start(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||||
|
let from = self.searched_to.saturating_sub(self.base) as usize;
|
||||||
|
if from >= self.freqs.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(hit) = find_vis(&self.freqs, self.sample_rate, from) {
|
||||||
|
if let Some(mode) = mode_for_vis(hit.code) {
|
||||||
|
self.begin(mode, self.base + hit.image_start as u64, events);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A header that parses but names a mode this decoder does not know
|
||||||
|
// is still a header: skip past it rather than finding it again.
|
||||||
|
tracing::debug!(vis = hit.code, "SSTV: unsupported mode");
|
||||||
|
self.searched_to = self.base + hit.image_start as u64;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuning in mid-transmission means no header to find. With a mode
|
||||||
|
// named in the configuration, the first sync pulse is enough to start.
|
||||||
|
if let Some(mode) = self.config.force_mode.and_then(mode_for_vis) {
|
||||||
|
if let Some(sync) = self.find_sync(from, self.freqs.len(), mode) {
|
||||||
|
let line_start = (self.base + sync as u64) as f64
|
||||||
|
- ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||||
|
self.begin_at(mode, line_start.max(0.0), events);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing yet. Rewind the cursor by a header's worth of samples before
|
||||||
|
// marking the buffer searched: audio arrives in blocks of a few
|
||||||
|
// milliseconds, so the search regularly runs over a start bit that is
|
||||||
|
// only half here. Advancing past it would retire the header for good
|
||||||
|
// on the strength of a partial view of it.
|
||||||
|
let unsearchable = ms_to_samples(HEADER_SPAN_MS, self.sample_rate) as u64;
|
||||||
|
let end = self.base + self.freqs.len() as u64;
|
||||||
|
self.searched_to = self.searched_to.max(end.saturating_sub(unsearchable));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin(&mut self, mode: &'static SstvMode, image_start: u64, events: &mut Vec<SstvEvent>) {
|
||||||
|
self.begin_at(mode, image_start as f64, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec<SstvEvent>) {
|
||||||
|
events.push(SstvEvent::Started {
|
||||||
|
vis: mode.vis,
|
||||||
|
mode: mode.name,
|
||||||
|
width: mode.width,
|
||||||
|
height: mode.height,
|
||||||
|
});
|
||||||
|
self.state = State::Receiving(Box::new(Reception {
|
||||||
|
mode,
|
||||||
|
canvas: ImageCanvas::new(usize::from(mode.width), usize::from(mode.height)),
|
||||||
|
next_line: line_start,
|
||||||
|
row: 0,
|
||||||
|
started_ms: now_ms(),
|
||||||
|
missing_syncs: 0,
|
||||||
|
first_line: true,
|
||||||
|
last_chroma_r: None,
|
||||||
|
last_chroma_b: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one transmitted line, if all of it has arrived.
|
||||||
|
fn try_line(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||||
|
let State::Receiving(reception) = &mut self.state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mode = reception.mode;
|
||||||
|
let line_samples = ms_to_samples(mode.line_ms, self.sample_rate);
|
||||||
|
let margin = ms_to_samples(SYNC_SEARCH_MS, self.sample_rate);
|
||||||
|
|
||||||
|
let start = reception.next_line;
|
||||||
|
// Enough for the line's own scans, and for the sync search to reach as
|
||||||
|
// far ahead as it looks — no further. Demanding the search margin past
|
||||||
|
// the end of every line would cost the last line of every picture,
|
||||||
|
// which is exactly where the transmission stops.
|
||||||
|
let sync_reach = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate) + margin;
|
||||||
|
let end = (start + line_samples).max(sync_reach);
|
||||||
|
let available = (self.base + self.freqs.len() as u64) as f64;
|
||||||
|
if end > available {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The line may begin before what is still buffered if the caller fed a
|
||||||
|
// huge block; nothing can be done about that but skip forward.
|
||||||
|
if start < self.base as f64 {
|
||||||
|
reception.next_line = self.base as f64;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line up on the sync pulse near where this line is predicted to be.
|
||||||
|
let expected_sync = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||||
|
let from = (expected_sync - margin).max(self.base as f64) as u64;
|
||||||
|
let to = (expected_sync + margin) as u64;
|
||||||
|
let searching = !matches!(&self.state, State::Receiving(r) if r.first_line);
|
||||||
|
let found = if searching {
|
||||||
|
self.find_sync_between(from, to, mode)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let State::Receiving(reception) = &mut self.state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let start = match found {
|
||||||
|
Some(sync_at) => {
|
||||||
|
reception.missing_syncs = 0;
|
||||||
|
let error = sync_at as f64 - expected_sync;
|
||||||
|
reception.next_line += error * SYNC_CORRECTION;
|
||||||
|
reception.next_line
|
||||||
|
}
|
||||||
|
None if reception.first_line => start,
|
||||||
|
None => {
|
||||||
|
reception.missing_syncs += 1;
|
||||||
|
start
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if reception.missing_syncs >= MISSING_SYNC_LIMIT {
|
||||||
|
let image = finish(reception);
|
||||||
|
self.state = State::Searching;
|
||||||
|
self.searched_to = self.base + self.freqs.len() as u64;
|
||||||
|
events.push(SstvEvent::Complete(image));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample every scan of the line, then colour the rows.
|
||||||
|
let mut scans: Vec<(Channel, Vec<u8>)> = Vec::new();
|
||||||
|
for (channel, offset_ms, ms) in mode.scans() {
|
||||||
|
let pixels = mode.scan_pixels(channel);
|
||||||
|
let at = start + ms_to_samples(offset_ms, self.sample_rate);
|
||||||
|
let values = sample_scan(&self.freqs, self.base, at, ms, pixels, self.sample_rate);
|
||||||
|
scans.push((channel, values));
|
||||||
|
}
|
||||||
|
|
||||||
|
let State::Receiving(reception) = &mut self.state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let rows = compose_rows(reception, &scans);
|
||||||
|
for (offset, row) in rows.into_iter().enumerate() {
|
||||||
|
let line = reception.row + offset as u16;
|
||||||
|
reception.canvas.put_row(usize::from(line), &row);
|
||||||
|
events.push(SstvEvent::Row { line, rgb: row });
|
||||||
|
}
|
||||||
|
reception.first_line = false;
|
||||||
|
reception.row += mode.lines_per_transmission;
|
||||||
|
reception.next_line += line_samples;
|
||||||
|
|
||||||
|
if reception.row >= mode.height {
|
||||||
|
let image = finish(reception);
|
||||||
|
self.state = State::Searching;
|
||||||
|
self.searched_to = self.base + self.freqs.len() as u64;
|
||||||
|
events.push(SstvEvent::Complete(image));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First sync pulse of about the right length in `freqs[from..to]`,
|
||||||
|
/// as an index of its leading edge.
|
||||||
|
///
|
||||||
|
/// Works on a smoothed copy of the window: a sync pulse is 1200 Hz, where
|
||||||
|
/// the raw per-sample estimate swings by ±95 Hz, so single samples cross
|
||||||
|
/// and re-cross the threshold throughout a pulse and no run is ever long
|
||||||
|
/// enough. Pixels are sampled from the raw signal, where averaging over
|
||||||
|
/// the pixel does the same job without blurring across its edges.
|
||||||
|
fn find_sync(&self, from: usize, to: usize, mode: &SstvMode) -> Option<usize> {
|
||||||
|
let want = sync_ms(mode);
|
||||||
|
let min_run = (ms_to_samples(want, self.sample_rate) * 0.6) as usize;
|
||||||
|
// A sync pulse ends. Silence and a dead carrier demodulate to near
|
||||||
|
// zero, which is below the threshold too, and without an upper bound a
|
||||||
|
// decoder left running on an empty channel finds sync everywhere and
|
||||||
|
// fills the picture with noise it invented.
|
||||||
|
let max_run = (ms_to_samples(want, self.sample_rate) * 3.0) as usize;
|
||||||
|
let to = to.min(self.freqs.len());
|
||||||
|
if from >= to {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Reach past the end of the search window by a whole pulse: a sync
|
||||||
|
// starting at the last moment the window allows still has to be
|
||||||
|
// measurable to its full length, or it is rejected for being short and
|
||||||
|
// the line it belongs to goes unaligned.
|
||||||
|
let pad = ms_to_samples(want + 2.0, self.sample_rate) as usize;
|
||||||
|
let window_from = from.saturating_sub(pad);
|
||||||
|
let window_to = (to + pad).min(self.freqs.len());
|
||||||
|
let smoothed = crate::demod::smooth(
|
||||||
|
&self.freqs[window_from..window_to],
|
||||||
|
ms_to_samples(1.0, self.sample_rate) as usize,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut i = from - window_from;
|
||||||
|
let scan_to = to - window_from;
|
||||||
|
while i < scan_to {
|
||||||
|
if smoothed[i] >= SYNC_THRESHOLD_HZ {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut run = 0;
|
||||||
|
while i + run < smoothed.len() && smoothed[i + run] < SYNC_THRESHOLD_HZ {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
if run >= min_run && run <= max_run {
|
||||||
|
return Some(window_from + i);
|
||||||
|
}
|
||||||
|
i += run.max(1);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`Self::find_sync`], over an absolute index range.
|
||||||
|
fn find_sync_between(&self, from: u64, to: u64, mode: &SstvMode) -> Option<u64> {
|
||||||
|
let from = from.saturating_sub(self.base) as usize;
|
||||||
|
let to = to.saturating_sub(self.base) as usize;
|
||||||
|
if from >= self.freqs.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.find_sync(from, to, mode)
|
||||||
|
.map(|at| self.base + at as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what is behind the decoder, so a long reception does not grow the
|
||||||
|
/// buffer without bound.
|
||||||
|
fn trim(&mut self) {
|
||||||
|
let keep_from = match &self.state {
|
||||||
|
State::Searching => {
|
||||||
|
let history = ms_to_samples(SEARCH_HISTORY_MS, self.sample_rate) as u64;
|
||||||
|
(self.base + self.freqs.len() as u64).saturating_sub(history)
|
||||||
|
}
|
||||||
|
State::Receiving(reception) => {
|
||||||
|
let margin = ms_to_samples(SYNC_SEARCH_MS * 2.0, self.sample_rate) as u64;
|
||||||
|
(reception.next_line as u64).saturating_sub(margin)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if keep_from <= self.base {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let drop = (keep_from - self.base) as usize;
|
||||||
|
if drop >= self.freqs.len() {
|
||||||
|
self.freqs.clear();
|
||||||
|
} else {
|
||||||
|
self.freqs.drain(..drop);
|
||||||
|
}
|
||||||
|
self.base = keep_from;
|
||||||
|
self.searched_to = self.searched_to.max(self.base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sync pulse length of a mode, read out of its own segment list.
|
||||||
|
fn sync_ms(mode: &SstvMode) -> f64 {
|
||||||
|
mode.segments
|
||||||
|
.iter()
|
||||||
|
.find_map(|segment| match segment {
|
||||||
|
crate::mode::Segment::Sync(ms) => Some(*ms),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(9.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the epoch, for stamping a picture with when it arrived.
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ms_to_samples(ms: f64, sample_rate: u32) -> f64 {
|
||||||
|
ms / 1000.0 * f64::from(sample_rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average the frequency across each pixel's window and turn it into a level.
|
||||||
|
///
|
||||||
|
/// The middle 60% of the window is used: a pixel's edges carry the
|
||||||
|
/// demodulator's transition from the pixel before, and including them smears
|
||||||
|
/// every edge in the picture.
|
||||||
|
fn sample_scan(
|
||||||
|
freqs: &[f32],
|
||||||
|
base: u64,
|
||||||
|
start: f64,
|
||||||
|
ms: f64,
|
||||||
|
pixels: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(pixels);
|
||||||
|
let width = ms_to_samples(ms, sample_rate) / pixels as f64;
|
||||||
|
for x in 0..pixels {
|
||||||
|
let pixel_start = start + width * x as f64;
|
||||||
|
let from = (pixel_start + width * 0.2 - base as f64).max(0.0) as usize;
|
||||||
|
let to = ((pixel_start + width * 0.8 - base as f64).max(0.0) as usize).min(freqs.len());
|
||||||
|
// A pixel narrower than a sample still has to produce one.
|
||||||
|
let (from, to) = if to > from {
|
||||||
|
(from, to)
|
||||||
|
} else {
|
||||||
|
let at = (pixel_start - base as f64).max(0.0) as usize;
|
||||||
|
(
|
||||||
|
at.min(freqs.len().saturating_sub(1)),
|
||||||
|
(at + 1).min(freqs.len()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if to <= from {
|
||||||
|
out.push(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let window = &freqs[from..to];
|
||||||
|
let mean = window.iter().sum::<f32>() / window.len() as f32;
|
||||||
|
out.push(level_from_hz(mean));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn one line's scans into image rows.
|
||||||
|
fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<Vec<u8>> {
|
||||||
|
let mode = reception.mode;
|
||||||
|
let width = usize::from(mode.width);
|
||||||
|
let find = |channel: Channel| scans.iter().find(|(c, _)| *c == channel).map(|(_, v)| v);
|
||||||
|
|
||||||
|
match mode.color {
|
||||||
|
ColorModel::Rgb => {
|
||||||
|
let red = find(Channel::Red);
|
||||||
|
let green = find(Channel::Green);
|
||||||
|
let blue = find(Channel::Blue);
|
||||||
|
let mut row = vec![0u8; width * 3];
|
||||||
|
for x in 0..width {
|
||||||
|
row[x * 3] = red.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||||
|
row[x * 3 + 1] = green.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||||
|
row[x * 3 + 2] = blue.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
vec![row]
|
||||||
|
}
|
||||||
|
ColorModel::YCrCb => {
|
||||||
|
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||||
|
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||||
|
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||||
|
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||||
|
}
|
||||||
|
ColorModel::YCrCbAlternating => {
|
||||||
|
// This line carries one chroma channel; the other is the one from
|
||||||
|
// the line before, which is what the mode expects a decoder to do.
|
||||||
|
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||||
|
let chroma = find(Channel::ChromaAlternating)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
if reception.row.is_multiple_of(2) {
|
||||||
|
reception.last_chroma_r = Some(chroma);
|
||||||
|
} else {
|
||||||
|
reception.last_chroma_b = Some(chroma);
|
||||||
|
}
|
||||||
|
let neutral = vec![128u8; luma.len().max(1) / 2];
|
||||||
|
let cr = reception
|
||||||
|
.last_chroma_r
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| neutral.clone());
|
||||||
|
let cb = reception.last_chroma_b.clone().unwrap_or(neutral);
|
||||||
|
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||||
|
}
|
||||||
|
ColorModel::YCrCbPaired => {
|
||||||
|
let odd = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||||
|
let even = find(Channel::LumaEven).cloned().unwrap_or_default();
|
||||||
|
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||||
|
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||||
|
vec![
|
||||||
|
ycrcb_row(&odd, &cr, &cb, width),
|
||||||
|
ycrcb_row(&even, &cr, &cb, width),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One RGB row from luminance and chrominance, stretching the chroma scans
|
||||||
|
/// across the width when they are narrower than it.
|
||||||
|
fn ycrcb_row(luma: &[u8], cr: &[u8], cb: &[u8], width: usize) -> Vec<u8> {
|
||||||
|
let mut row = vec![0u8; width * 3];
|
||||||
|
let pick = |channel: &[u8], x: usize| -> f32 {
|
||||||
|
if channel.is_empty() {
|
||||||
|
return 128.0;
|
||||||
|
}
|
||||||
|
let at = x * channel.len() / width.max(1);
|
||||||
|
f32::from(channel[at.min(channel.len() - 1)])
|
||||||
|
};
|
||||||
|
for x in 0..width {
|
||||||
|
let y = if luma.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
let at = x * luma.len() / width.max(1);
|
||||||
|
f32::from(luma[at.min(luma.len() - 1)])
|
||||||
|
};
|
||||||
|
let (r, g, b) = ycrcb_to_rgb(y, pick(cr, x), pick(cb, x));
|
||||||
|
row[x * 3] = r;
|
||||||
|
row[x * 3 + 1] = g;
|
||||||
|
row[x * 3 + 2] = b;
|
||||||
|
}
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of the studio-swing conversion SSTV encoders use.
|
||||||
|
fn ycrcb_to_rgb(y: f32, cr: f32, cb: f32) -> (u8, u8, u8) {
|
||||||
|
let r = 298.082 * y / 256.0 + 408.583 * cr / 256.0 - 222.921;
|
||||||
|
let g = 298.082 * y / 256.0 - 100.291 * cb / 256.0 - 208.120 * cr / 256.0 + 135.576;
|
||||||
|
let b = 298.082 * y / 256.0 + 516.412 * cb / 256.0 - 276.836;
|
||||||
|
(
|
||||||
|
r.clamp(0.0, 255.0) as u8,
|
||||||
|
g.clamp(0.0, 255.0) as u8,
|
||||||
|
b.clamp(0.0, 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(reception: &Reception) -> SstvImage {
|
||||||
|
SstvImage {
|
||||||
|
vis: reception.mode.vis,
|
||||||
|
mode: reception.mode.name,
|
||||||
|
width: reception.mode.width,
|
||||||
|
height: reception.mode.height,
|
||||||
|
lines: reception.canvas.filled_rows() as u16,
|
||||||
|
complete: reception.row >= reception.mode.height,
|
||||||
|
rgb: reception.canvas.rgb().to_vec(),
|
||||||
|
started_ms: reception.started_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_stamps_are_utc_and_sort_in_time_order() {
|
||||||
|
// Known instants, checked against `date -u -r <secs>`.
|
||||||
|
assert_eq!(stamp(0), "19700101T000000Z");
|
||||||
|
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
|
||||||
|
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
|
||||||
|
// Sorting the names sorts the pictures.
|
||||||
|
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Instantaneous frequency estimation.
|
||||||
|
//!
|
||||||
|
//! SSTV carries every pixel as a frequency between 1500 and 2300 Hz, and every
|
||||||
|
//! line boundary as a 1200 Hz pulse, so one measurement serves the whole
|
||||||
|
//! decoder: the frequency of the signal at each sample. A Hilbert transform
|
||||||
|
//! FIR forms the analytic signal and the phase difference between consecutive
|
||||||
|
//! samples gives the frequency.
|
||||||
|
//!
|
||||||
|
//! The same approach drives the WEFAX decoder, which maps the result straight
|
||||||
|
//! to luminance. Here the frequency itself is the output, because the VIS
|
||||||
|
//! header and the sync detector read tones far outside the pixel band.
|
||||||
|
//!
|
||||||
|
//! Block-based linear processing, per `docs/Optimization-Guidelines.md`: the
|
||||||
|
//! FIR runs over a contiguous `[tail | samples]` buffer so the inner loop is
|
||||||
|
//! straight indexing the compiler can vectorise.
|
||||||
|
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
/// Taps for the Hilbert transform FIR. Odd, so the delay is a whole sample.
|
||||||
|
const HILBERT_TAPS: usize = 65;
|
||||||
|
|
||||||
|
/// Group delay of the FIR, in samples.
|
||||||
|
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
|
||||||
|
|
||||||
|
/// Taps for the input band-pass. Long enough to be worth having, short enough
|
||||||
|
/// that its delay is a couple of milliseconds.
|
||||||
|
const BANDPASS_TAPS: usize = 127;
|
||||||
|
|
||||||
|
/// The band SSTV lives in: sync at 1200 Hz, black at 1500, white at 2300.
|
||||||
|
const BAND_LOW_HZ: f32 = 900.0;
|
||||||
|
const BAND_HIGH_HZ: f32 = 2700.0;
|
||||||
|
|
||||||
|
/// Produces instantaneous frequency in Hz from real audio samples.
|
||||||
|
pub struct FreqDemod {
|
||||||
|
/// Band-pass, applied first. A phase-difference frequency detector answers
|
||||||
|
/// whatever is loudest, so hiss outside the SSTV band steers the estimate
|
||||||
|
/// even when the signal is much stronger — the picture tears rather than
|
||||||
|
/// grows grainy. Every real decoder filters to the band first.
|
||||||
|
bandpass: Vec<f32>,
|
||||||
|
bandpass_tail: Vec<f32>,
|
||||||
|
coeffs: [f32; HILBERT_TAPS],
|
||||||
|
/// The last `HILBERT_TAPS - 1` input samples, priming the next block.
|
||||||
|
tail: Vec<f32>,
|
||||||
|
prev_i: f32,
|
||||||
|
prev_q: f32,
|
||||||
|
/// `sample_rate / 2π`, the constant turning phase step into Hz.
|
||||||
|
hz_per_radian: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FreqDemod {
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
bandpass: design_bandpass_fir(sample_rate),
|
||||||
|
bandpass_tail: vec![0.0; BANDPASS_TAPS - 1],
|
||||||
|
coeffs: design_hilbert_fir(),
|
||||||
|
tail: vec![0.0; HILBERT_TAPS - 1],
|
||||||
|
prev_i: 0.0,
|
||||||
|
prev_q: 0.0,
|
||||||
|
hz_per_radian: sample_rate as f32 / (2.0 * PI),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Band-pass a block, carrying the filter's state across the seam.
|
||||||
|
fn filter(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||||
|
let taps = BANDPASS_TAPS;
|
||||||
|
let tail_len = taps - 1;
|
||||||
|
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||||
|
work.extend_from_slice(&self.bandpass_tail);
|
||||||
|
work.extend_from_slice(samples);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(samples.len());
|
||||||
|
for i in 0..samples.len() {
|
||||||
|
let window = &work[i..i + taps];
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
for k in 0..taps {
|
||||||
|
acc += self.bandpass[k] * window[taps - 1 - k];
|
||||||
|
}
|
||||||
|
out.push(acc);
|
||||||
|
}
|
||||||
|
let work_len = work.len();
|
||||||
|
self.bandpass_tail
|
||||||
|
.copy_from_slice(&work[work_len - tail_len..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frequency in Hz for each input sample, appended to `out`.
|
||||||
|
///
|
||||||
|
/// Output is delayed by the FIR's group delay, which is constant and so
|
||||||
|
/// affects only the absolute timing of the whole stream, not the spacing
|
||||||
|
/// between the events in it.
|
||||||
|
pub fn process_into(&mut self, samples: &[f32], out: &mut Vec<f32>) {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let samples = self.filter(samples);
|
||||||
|
let samples = samples.as_slice();
|
||||||
|
let taps = HILBERT_TAPS;
|
||||||
|
let tail_len = taps - 1;
|
||||||
|
|
||||||
|
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||||
|
work.extend_from_slice(&self.tail);
|
||||||
|
work.extend_from_slice(samples);
|
||||||
|
|
||||||
|
out.reserve(samples.len());
|
||||||
|
for i in 0..samples.len() {
|
||||||
|
let window = &work[i..i + taps];
|
||||||
|
let mut q = 0.0f32;
|
||||||
|
for k in 0..taps {
|
||||||
|
q += self.coeffs[k] * window[taps - 1 - k];
|
||||||
|
}
|
||||||
|
// In phase with the quadrature output: the input, delayed by the
|
||||||
|
// FIR's own group delay.
|
||||||
|
let i_val = window[HILBERT_DELAY];
|
||||||
|
|
||||||
|
// f = |arg(z[n] · conj(z[n-1]))| · fs / 2π
|
||||||
|
let di = i_val * self.prev_i + q * self.prev_q;
|
||||||
|
let dq = q * self.prev_i - i_val * self.prev_q;
|
||||||
|
out.push(dq.atan2(di).abs() * self.hz_per_radian);
|
||||||
|
|
||||||
|
self.prev_i = i_val;
|
||||||
|
self.prev_q = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
let work_len = work.len();
|
||||||
|
self.tail.copy_from_slice(&work[work_len - tail_len..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.bandpass_tail.fill(0.0);
|
||||||
|
self.tail.fill(0.0);
|
||||||
|
self.prev_i = 0.0;
|
||||||
|
self.prev_q = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boxcar mean over `window` samples, centred, returning one value per input.
|
||||||
|
///
|
||||||
|
/// The per-sample estimate ripples — badly at the low end of the band, where
|
||||||
|
/// the Hilbert approximation is weakest: a clean 1200 Hz tone reads anywhere
|
||||||
|
/// between 1110 and 1300 Hz sample to sample, though its mean is exact. Pixels
|
||||||
|
/// are averaged over their own window and so come out right regardless, but
|
||||||
|
/// anything that classifies a single sample by frequency — the VIS bits, the
|
||||||
|
/// sync pulses — has to look at a mean or it is reading the ripple.
|
||||||
|
pub fn smooth(freqs: &[f32], window: usize) -> Vec<f32> {
|
||||||
|
let window = window.max(1);
|
||||||
|
if freqs.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Prefix sums in f64: a minute of audio is three million samples, and a
|
||||||
|
// running f32 total drifts long before that.
|
||||||
|
let mut prefix = Vec::with_capacity(freqs.len() + 1);
|
||||||
|
prefix.push(0.0f64);
|
||||||
|
for &freq in freqs {
|
||||||
|
prefix.push(prefix[prefix.len() - 1] + f64::from(freq));
|
||||||
|
}
|
||||||
|
let half = window / 2;
|
||||||
|
(0..freqs.len())
|
||||||
|
.map(|i| {
|
||||||
|
// Shrinks at the ends rather than reaching past them.
|
||||||
|
let from = i.saturating_sub(half);
|
||||||
|
let to = (i + window - half).min(freqs.len());
|
||||||
|
((prefix[to] - prefix[from]) / (to - from) as f64) as f32
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windowed-sinc band-pass over the SSTV band, Hamming-windowed and
|
||||||
|
/// linear-phase, so every frequency in the band is delayed alike.
|
||||||
|
fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
|
||||||
|
let sr = sample_rate as f64;
|
||||||
|
let low = f64::from(BAND_LOW_HZ) / sr;
|
||||||
|
let high = (f64::from(BAND_HIGH_HZ) / sr).min(0.499);
|
||||||
|
let m = (BANDPASS_TAPS - 1) as f64;
|
||||||
|
let mid = m / 2.0;
|
||||||
|
let sinc = |x: f64| {
|
||||||
|
if x.abs() < 1e-9 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
|
||||||
|
for i in 0..BANDPASS_TAPS {
|
||||||
|
let n = i as f64 - mid;
|
||||||
|
// Difference of two low-passes is a band-pass.
|
||||||
|
let ideal = 2.0 * high * sinc(2.0 * high * n) - 2.0 * low * sinc(2.0 * low * n);
|
||||||
|
let window = 0.54 - 0.46 * (2.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||||
|
coeffs.push((ideal * window) as f32);
|
||||||
|
}
|
||||||
|
coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type III FIR approximating a 90° phase shift: h[n] = 2/(πn) for odd n,
|
||||||
|
/// Blackman-windowed. Independent of sample rate, so the decoder can run at
|
||||||
|
/// whatever rate the audio arrives in.
|
||||||
|
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
|
||||||
|
let mut coeffs = [0.0f32; HILBERT_TAPS];
|
||||||
|
let m = (HILBERT_TAPS - 1) as f64;
|
||||||
|
let mid = m / 2.0;
|
||||||
|
for (i, coeff) in coeffs.iter_mut().enumerate() {
|
||||||
|
let n = i as f64 - mid;
|
||||||
|
let ni = n.round() as i64;
|
||||||
|
if ni != 0 && ni % 2 != 0 {
|
||||||
|
let h = 2.0 / (std::f64::consts::PI * n);
|
||||||
|
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
||||||
|
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||||
|
*coeff = (h * w) as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tone(freq: f32, sample_rate: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|n| (2.0 * PI * freq * n as f32 / sample_rate as f32).sin())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measured on the settled part of the output: the first `HILBERT_TAPS`
|
||||||
|
/// samples are the filter filling up.
|
||||||
|
fn measure(freq: f32, sample_rate: u32) -> f32 {
|
||||||
|
let mut demod = FreqDemod::new(sample_rate);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
demod.process_into(
|
||||||
|
&tone(freq, sample_rate, sample_rate as usize / 10),
|
||||||
|
&mut out,
|
||||||
|
);
|
||||||
|
let settled = &out[HILBERT_TAPS * 2..];
|
||||||
|
settled.iter().sum::<f32>() / settled.len() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_the_tones_sstv_is_made_of() {
|
||||||
|
for rate in [8000u32, 11025, 44100, 48000] {
|
||||||
|
for freq in [1200.0f32, 1500.0, 1900.0, 2300.0] {
|
||||||
|
let measured = measure(freq, rate);
|
||||||
|
assert!(
|
||||||
|
(measured - freq).abs() < 5.0,
|
||||||
|
"{rate} Hz: {freq} Hz tone measured as {measured:.1} Hz",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks are whatever size the audio pipeline hands over, and a tone that
|
||||||
|
/// straddles two of them must not produce a discontinuity at the seam.
|
||||||
|
#[test]
|
||||||
|
fn block_boundaries_do_not_disturb_the_estimate() {
|
||||||
|
let rate = 48_000;
|
||||||
|
let samples = tone(1900.0, rate, 9600);
|
||||||
|
let mut whole = FreqDemod::new(rate);
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
whole.process_into(&samples, &mut expected);
|
||||||
|
|
||||||
|
let mut split = FreqDemod::new(rate);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
for chunk in samples.chunks(137) {
|
||||||
|
split.process_into(chunk, &mut actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(actual.len(), expected.len());
|
||||||
|
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
|
||||||
|
assert!((a - b).abs() < 0.01, "sample {i}: {a} vs {b}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follows_a_step_between_tones_within_a_pixel() {
|
||||||
|
let rate = 48_000;
|
||||||
|
let mut samples = tone(1500.0, rate, 4800);
|
||||||
|
samples.extend(tone(2300.0, rate, 4800));
|
||||||
|
let mut demod = FreqDemod::new(rate);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
demod.process_into(&samples, &mut out);
|
||||||
|
|
||||||
|
// Well before the step it reads black; well after it, white. The step
|
||||||
|
// itself takes the filter's length to pass through.
|
||||||
|
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
|
||||||
|
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
|
||||||
|
assert!(
|
||||||
|
(before - 1500.0).abs() < 10.0,
|
||||||
|
"before the step: {before:.1} Hz"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(after - 2300.0).abs() < 10.0,
|
||||||
|
"after the step: {after:.1} Hz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Turning an image into an SSTV signal.
|
||||||
|
//!
|
||||||
|
//! This exists so the decoder can be held to a picture rather than to a
|
||||||
|
//! description of one: the tests encode a known image, decode the audio back
|
||||||
|
//! and compare. Nothing in the receive path uses it.
|
||||||
|
//!
|
||||||
|
//! It is written to the same mode table the decoder reads, which makes a
|
||||||
|
//! round-trip a test of the decoder and not of the timings — the timings are
|
||||||
|
//! checked separately, against the published line durations, in [`crate::mode`].
|
||||||
|
|
||||||
|
use crate::mode::{Channel, Segment, SstvMode, BLACK_HZ, SYNC_HZ, WHITE_HZ};
|
||||||
|
|
||||||
|
/// A stretch of constant tone.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Tone {
|
||||||
|
pub hz: f32,
|
||||||
|
pub ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frequency for an 8-bit level: the inverse of [`crate::mode::level_from_hz`].
|
||||||
|
pub fn hz_from_level(level: u8) -> f32 {
|
||||||
|
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f32::from(level) / 255.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tones of a VIS header announcing `vis`.
|
||||||
|
pub fn vis_tones(vis: u8) -> Vec<Tone> {
|
||||||
|
let mut tones = vec![
|
||||||
|
Tone {
|
||||||
|
hz: 1900.0,
|
||||||
|
ms: 300.0,
|
||||||
|
},
|
||||||
|
Tone {
|
||||||
|
hz: 1200.0,
|
||||||
|
ms: 10.0,
|
||||||
|
},
|
||||||
|
Tone {
|
||||||
|
hz: 1900.0,
|
||||||
|
ms: 300.0,
|
||||||
|
},
|
||||||
|
Tone {
|
||||||
|
hz: 1200.0,
|
||||||
|
ms: 30.0,
|
||||||
|
}, // start bit
|
||||||
|
];
|
||||||
|
let mut ones = 0;
|
||||||
|
for bit in 0..7 {
|
||||||
|
let set = vis & (1 << bit) != 0;
|
||||||
|
if set {
|
||||||
|
ones += 1;
|
||||||
|
}
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: if set { 1100.0 } else { 1300.0 },
|
||||||
|
ms: 30.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Even parity over the seven data bits.
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 },
|
||||||
|
ms: 30.0,
|
||||||
|
});
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: 1200.0,
|
||||||
|
ms: 30.0,
|
||||||
|
}); // stop bit
|
||||||
|
tones
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGB source for the encoder: `width * height * 3` bytes.
|
||||||
|
pub struct Frame<'a> {
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
pub rgb: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Frame<'_> {
|
||||||
|
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||||
|
let x = x.min(self.width.saturating_sub(1));
|
||||||
|
let y = y.min(self.height.saturating_sub(1));
|
||||||
|
let at = (y * self.width + x) * 3;
|
||||||
|
(self.rgb[at], self.rgb[at + 1], self.rgb[at + 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour components SSTV actually sends, for a pixel.
|
||||||
|
fn ycrcb(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||||
|
let (r, g, b) = self.pixel(x, y);
|
||||||
|
let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b));
|
||||||
|
let y_val = 16.0 + (0.003_906 * ((65.738 * r) + (129.057 * g) + (25.064 * b)));
|
||||||
|
let cr = 128.0 + (0.003_906 * ((112.439 * r) + (-94.154 * g) + (-18.285 * b)));
|
||||||
|
let cb = 128.0 + (0.003_906 * ((-37.945 * r) + (-74.494 * g) + (112.439 * b)));
|
||||||
|
(
|
||||||
|
y_val.clamp(0.0, 255.0) as u8,
|
||||||
|
cr.clamp(0.0, 255.0) as u8,
|
||||||
|
cb.clamp(0.0, 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode `frame` in `mode`, returning the tones of the whole transmission.
|
||||||
|
pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
|
||||||
|
let mut tones = vis_tones(mode.vis);
|
||||||
|
let per_line = usize::from(mode.lines_per_transmission);
|
||||||
|
let transmissions = usize::from(mode.height) / per_line;
|
||||||
|
|
||||||
|
for transmission in 0..transmissions {
|
||||||
|
let top = transmission * per_line;
|
||||||
|
for segment in mode.segments {
|
||||||
|
match *segment {
|
||||||
|
Segment::Sync(ms) => tones.push(Tone { hz: SYNC_HZ, ms }),
|
||||||
|
Segment::Gap(ms) => tones.push(Tone { hz: BLACK_HZ, ms }),
|
||||||
|
Segment::Scan { channel, ms } => {
|
||||||
|
// A chroma scan carries fewer pixels than the image is
|
||||||
|
// wide, and takes proportionally less time per pixel.
|
||||||
|
let pixels = mode.scan_pixels(channel);
|
||||||
|
let pixel_ms = ms / pixels as f64;
|
||||||
|
for x in 0..pixels {
|
||||||
|
let level = channel_level(mode, frame, channel, x, top, transmission);
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: hz_from_level(level),
|
||||||
|
ms: pixel_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tones
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_level(
|
||||||
|
mode: &SstvMode,
|
||||||
|
frame: &Frame<'_>,
|
||||||
|
channel: Channel,
|
||||||
|
x: usize,
|
||||||
|
top: usize,
|
||||||
|
transmission: usize,
|
||||||
|
) -> u8 {
|
||||||
|
match channel {
|
||||||
|
Channel::Red => frame.pixel(x, top).0,
|
||||||
|
Channel::Green => frame.pixel(x, top).1,
|
||||||
|
Channel::Blue => frame.pixel(x, top).2,
|
||||||
|
Channel::LumaOdd => frame.ycrcb(x, top).0,
|
||||||
|
Channel::LumaEven => frame.ycrcb(x, top + 1).0,
|
||||||
|
Channel::ChromaR => chroma(mode, frame, x, top, true),
|
||||||
|
Channel::ChromaB => chroma(mode, frame, x, top, false),
|
||||||
|
// Robot 36 sends R-Y on odd transmitted lines and B-Y on even ones.
|
||||||
|
Channel::ChromaAlternating => chroma(mode, frame, x, top, transmission.is_multiple_of(2)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chroma scans are half the width of the image in the Robot modes, so each
|
||||||
|
/// value covers two pixels; PD averages the two image lines of the pair too.
|
||||||
|
fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: bool) -> u8 {
|
||||||
|
let scale = usize::from(mode.width) / mode.scan_pixels(Channel::ChromaR).max(1);
|
||||||
|
let x0 = x * scale;
|
||||||
|
let mut total = 0u32;
|
||||||
|
let mut count = 0u32;
|
||||||
|
let rows = usize::from(mode.lines_per_transmission);
|
||||||
|
for row in 0..rows {
|
||||||
|
for dx in 0..scale {
|
||||||
|
let (_, cr, cb) = frame.ycrcb(x0 + dx, top + row);
|
||||||
|
total += u32::from(if want_cr { cr } else { cb });
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(total / count.max(1)) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render tones to audio at `sample_rate`, with continuous phase so the
|
||||||
|
/// demodulator sees no step at a tone boundary that isn't in the signal.
|
||||||
|
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||||
|
let sr = f64::from(sample_rate);
|
||||||
|
let mut out =
|
||||||
|
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
|
||||||
|
let mut phase = 0.0f64;
|
||||||
|
// Each tone's *end* is rounded to a sample, rather than its length: a
|
||||||
|
// pixel of 25.5 samples rounded up on its own puts a whole line 600
|
||||||
|
// samples late by the end of it, which is a timing error no receiver
|
||||||
|
// should have to chase and no transmitter would produce.
|
||||||
|
let mut elapsed_ms = 0.0f64;
|
||||||
|
let mut emitted = 0usize;
|
||||||
|
for tone in tones {
|
||||||
|
elapsed_ms += tone.ms;
|
||||||
|
let end = (elapsed_ms / 1000.0 * sr).round() as usize;
|
||||||
|
let samples = end.saturating_sub(emitted);
|
||||||
|
emitted = end;
|
||||||
|
let step = 2.0 * std::f64::consts::PI * f64::from(tone.hz) / sr;
|
||||||
|
for _ in 0..samples {
|
||||||
|
out.push(phase.sin() as f32);
|
||||||
|
phase += step;
|
||||||
|
if phase > std::f64::consts::TAU {
|
||||||
|
phase -= std::f64::consts::TAU;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a frame straight to audio.
|
||||||
|
pub fn encode(mode: &SstvMode, frame: &Frame<'_>, sample_rate: u32) -> Vec<f32> {
|
||||||
|
render(&encode_tones(mode, frame), sample_rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mode::{level_from_hz, mode_for_vis};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levels_survive_the_trip_through_frequency() {
|
||||||
|
for level in [0u8, 1, 64, 128, 200, 255] {
|
||||||
|
assert_eq!(level_from_hz(hz_from_level(level)), level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_transmission_lasts_as_long_as_the_mode_says() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let rgb = vec![128u8; 320 * 256 * 3];
|
||||||
|
let frame = Frame {
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
rgb: &rgb,
|
||||||
|
};
|
||||||
|
let audio = encode(mode, &frame, 48_000);
|
||||||
|
// Header plus 256 lines, within a line of the published duration.
|
||||||
|
let header_s = 0.94;
|
||||||
|
let expected = header_s + mode.frame_secs();
|
||||||
|
let actual = audio.len() as f64 / 48_000.0;
|
||||||
|
assert!(
|
||||||
|
(actual - expected).abs() < mode.line_ms / 1000.0,
|
||||||
|
"encoded {actual:.2} s, expected {expected:.2} s",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Assembling decoded lines into an image, and getting it out of the process.
|
||||||
|
//!
|
||||||
|
//! Rows arrive one at a time and the picture is worth looking at before it is
|
||||||
|
//! finished, so the assembler holds a full-size RGB canvas from the start and
|
||||||
|
//! fills it in. An unfinished frame is grey below the last decoded row rather
|
||||||
|
//! than black, which reads as "not here yet" instead of "received as black".
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
/// Value the canvas starts at: mid-grey, for rows not yet received.
|
||||||
|
const UNWRITTEN: u8 = 96;
|
||||||
|
|
||||||
|
pub struct ImageCanvas {
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
rgb: Vec<u8>,
|
||||||
|
/// Highest row index written, plus one.
|
||||||
|
filled_rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCanvas {
|
||||||
|
pub fn new(width: usize, height: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: vec![UNWRITTEN; width * height * 3],
|
||||||
|
filled_rows: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> usize {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows written so far.
|
||||||
|
pub fn filled_rows(&self) -> usize {
|
||||||
|
self.filled_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one row of RGB triples. Rows past the bottom of the image are
|
||||||
|
/// dropped: a transmission that runs long is not a reason to grow.
|
||||||
|
pub fn put_row(&mut self, y: usize, row: &[u8]) {
|
||||||
|
if y >= self.height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let at = y * self.width * 3;
|
||||||
|
let take = row.len().min(self.width * 3);
|
||||||
|
self.rgb[at..at + take].copy_from_slice(&row[..take]);
|
||||||
|
self.filled_rows = self.filled_rows.max(y + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row(&self, y: usize) -> Option<&[u8]> {
|
||||||
|
if y >= self.height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let at = y * self.width * 3;
|
||||||
|
Some(&self.rgb[at..at + self.width * 3])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rgb(&self) -> &[u8] {
|
||||||
|
&self.rgb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode the canvas as a PNG.
|
||||||
|
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
|
||||||
|
encoder.set_color(png::ColorType::Rgb);
|
||||||
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
|
let mut writer = encoder
|
||||||
|
.write_header()
|
||||||
|
.map_err(|e| format!("PNG header: {e}"))?;
|
||||||
|
writer
|
||||||
|
.write_image_data(&self.rgb)
|
||||||
|
.map_err(|e| format!("PNG data: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PNG, base64-encoded for the journey to a browser.
|
||||||
|
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(self.to_png()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the PNG into `dir`, named for when and where it was received.
|
||||||
|
pub fn save_png(
|
||||||
|
&self,
|
||||||
|
dir: &Path,
|
||||||
|
freq_hz: u64,
|
||||||
|
mode_name: &str,
|
||||||
|
stamp: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
|
||||||
|
let slug: String = mode_name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||||
|
.collect();
|
||||||
|
let path = dir.join(format!("SSTV_{stamp}_{freq_hz}_{slug}.png"));
|
||||||
|
std::fs::write(&path, self.to_png()?)
|
||||||
|
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_land_where_they_are_put_and_the_rest_stays_unwritten() {
|
||||||
|
let mut canvas = ImageCanvas::new(4, 3);
|
||||||
|
canvas.put_row(1, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||||
|
assert_eq!(canvas.row(1).unwrap()[0..3], [1, 2, 3]);
|
||||||
|
assert_eq!(canvas.row(0).unwrap()[0], UNWRITTEN);
|
||||||
|
assert_eq!(canvas.filled_rows(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_past_the_bottom_is_dropped_rather_than_growing_the_image() {
|
||||||
|
let mut canvas = ImageCanvas::new(2, 2);
|
||||||
|
canvas.put_row(9, &[1, 2, 3, 4, 5, 6]);
|
||||||
|
assert_eq!(canvas.filled_rows(), 0);
|
||||||
|
assert_eq!(canvas.rgb().len(), 2 * 2 * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_a_png_a_decoder_can_read_back() {
|
||||||
|
let mut canvas = ImageCanvas::new(2, 2);
|
||||||
|
canvas.put_row(0, &[255, 0, 0, 0, 255, 0]);
|
||||||
|
let png_bytes = canvas.to_png().expect("png");
|
||||||
|
let decoder = png::Decoder::new(png_bytes.as_slice());
|
||||||
|
let mut reader = decoder.read_info().expect("png info");
|
||||||
|
let mut buf = vec![0; reader.output_buffer_size()];
|
||||||
|
let info = reader.next_frame(&mut buf).expect("png frame");
|
||||||
|
assert_eq!((info.width, info.height), (2, 2));
|
||||||
|
assert_eq!(&buf[0..6], &[255, 0, 0, 0, 255, 0]);
|
||||||
|
assert!(!canvas.to_png_base64().expect("base64").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! SSTV (Slow-Scan Television) decoder.
|
||||||
|
//!
|
||||||
|
//! Pure Rust, covering Martin, Scottie, Robot, PD and Wraase SC2-180, with the
|
||||||
|
//! mode taken from the VIS header that precedes every transmission. Rows are
|
||||||
|
//! emitted as they arrive so a picture can be watched building up.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||||
|
//!
|
||||||
|
//! let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||||
|
//! # let audio: Vec<f32> = Vec::new();
|
||||||
|
//! for event in decoder.process_samples(&audio) {
|
||||||
|
//! match event {
|
||||||
|
//! SstvEvent::Started { mode, .. } => println!("receiving {mode}"),
|
||||||
|
//! SstvEvent::Row { line, .. } => println!("line {line}"),
|
||||||
|
//! SstvEvent::Complete(image) => println!("{} lines", image.lines),
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod demod;
|
||||||
|
pub mod encode;
|
||||||
|
pub mod image;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod vis;
|
||||||
|
|
||||||
|
pub use config::SstvConfig;
|
||||||
|
pub use decoder::{SstvDecoder, SstvEvent, SstvImage};
|
||||||
|
pub use image::ImageCanvas;
|
||||||
|
pub use mode::{mode_for_vis, SstvMode, MODES};
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! SSTV mode table: what a VIS code means in timings, geometry and colour.
|
||||||
|
//!
|
||||||
|
//! Every mode transmits a line as a sequence of *segments* — a sync pulse, a
|
||||||
|
//! porch or separator at a fixed tone, and one scan per colour channel. The
|
||||||
|
//! decoder needs only the segment layout and the offset of each scan within
|
||||||
|
//! the line, so that is what a [`SstvMode`] is: a list of segments plus the
|
||||||
|
//! rules for turning the scans back into pixels.
|
||||||
|
//!
|
||||||
|
//! Timings follow the published mode specifications (JL Barber, N7CXI,
|
||||||
|
//! "Proposal for SSTV Mode Specifications", 2000), which is the same table
|
||||||
|
//! MMSSTV, QSSTV and slowrx work from.
|
||||||
|
|
||||||
|
/// Tone that marks a line boundary, in Hz.
|
||||||
|
pub const SYNC_HZ: f32 = 1200.0;
|
||||||
|
/// Tone for black, in Hz.
|
||||||
|
pub const BLACK_HZ: f32 = 1500.0;
|
||||||
|
/// Tone for white, in Hz.
|
||||||
|
pub const WHITE_HZ: f32 = 2300.0;
|
||||||
|
|
||||||
|
/// What a scan segment carries.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Channel {
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Blue,
|
||||||
|
/// Luminance for the odd (first) image line of the pair.
|
||||||
|
LumaOdd,
|
||||||
|
/// Luminance for the even (second) image line of a PD pair.
|
||||||
|
LumaEven,
|
||||||
|
/// R-Y chrominance.
|
||||||
|
ChromaR,
|
||||||
|
/// B-Y chrominance.
|
||||||
|
ChromaB,
|
||||||
|
/// Robot 36 alternates R-Y and B-Y between transmitted lines: odd lines
|
||||||
|
/// carry R-Y, even lines B-Y, and each is held over both.
|
||||||
|
ChromaAlternating,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One piece of a transmitted line.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Segment {
|
||||||
|
/// Sync pulse at [`SYNC_HZ`].
|
||||||
|
Sync(f64),
|
||||||
|
/// Porch, separator or gap at a fixed tone; the tone itself is not decoded.
|
||||||
|
Gap(f64),
|
||||||
|
/// A scan carrying pixels for one channel.
|
||||||
|
Scan { channel: Channel, ms: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Segment {
|
||||||
|
pub fn duration_ms(&self) -> f64 {
|
||||||
|
match *self {
|
||||||
|
Segment::Sync(ms) | Segment::Gap(ms) => ms,
|
||||||
|
Segment::Scan { ms, .. } => ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the scans of a line become pixels.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ColorModel {
|
||||||
|
/// Scans are red, green and blue directly (Martin, Scottie, Wraase).
|
||||||
|
Rgb,
|
||||||
|
/// Y plus one alternating chroma channel per line (Robot 36).
|
||||||
|
YCrCbAlternating,
|
||||||
|
/// Y, R-Y and B-Y in every line (Robot 72).
|
||||||
|
YCrCb,
|
||||||
|
/// Two image lines per transmitted line: Y odd, R-Y, B-Y, Y even (PD).
|
||||||
|
YCrCbPaired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decodable SSTV mode.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SstvMode {
|
||||||
|
/// VIS code as sent in the header.
|
||||||
|
pub vis: u8,
|
||||||
|
/// Human-readable name, e.g. "Martin M1".
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Pixels across.
|
||||||
|
pub width: u16,
|
||||||
|
/// Image lines in a full frame.
|
||||||
|
pub height: u16,
|
||||||
|
/// Transmitted line duration in milliseconds.
|
||||||
|
pub line_ms: f64,
|
||||||
|
/// Segments in transmission order.
|
||||||
|
pub segments: &'static [Segment],
|
||||||
|
pub color: ColorModel,
|
||||||
|
/// Image lines produced by one transmitted line (2 for PD, otherwise 1).
|
||||||
|
pub lines_per_transmission: u16,
|
||||||
|
/// Offset from the start of a line to the leading edge of its sync pulse.
|
||||||
|
/// Zero for most modes; Scottie sends the sync in the middle of the line,
|
||||||
|
/// so a line detected at its sync starts before it.
|
||||||
|
pub sync_offset_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SstvMode {
|
||||||
|
/// Total of every segment, which must equal [`SstvMode::line_ms`].
|
||||||
|
pub fn segments_ms(&self) -> f64 {
|
||||||
|
self.segments.iter().map(Segment::duration_ms).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start offset, in milliseconds from the line start, of each scan.
|
||||||
|
pub fn scans(&self) -> Vec<(Channel, f64, f64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut at = 0.0;
|
||||||
|
for segment in self.segments {
|
||||||
|
if let Segment::Scan { channel, ms } = *segment {
|
||||||
|
out.push((channel, at, ms));
|
||||||
|
}
|
||||||
|
at += segment.duration_ms();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixels carried by a scan of this channel.
|
||||||
|
///
|
||||||
|
/// Chroma is sent at half the width in the Robot modes — the eye takes
|
||||||
|
/// colour more coarsely than brightness, and the saving is what makes 36
|
||||||
|
/// seconds possible. PD sends chroma at full width and saves its time by
|
||||||
|
/// sharing one pair of chroma scans between two image lines instead.
|
||||||
|
pub fn scan_pixels(&self, channel: Channel) -> usize {
|
||||||
|
let width = usize::from(self.width);
|
||||||
|
match channel {
|
||||||
|
Channel::ChromaR | Channel::ChromaB | Channel::ChromaAlternating
|
||||||
|
if self.color != ColorModel::YCrCbPaired =>
|
||||||
|
{
|
||||||
|
width / 2
|
||||||
|
}
|
||||||
|
_ => width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds a full frame takes to send.
|
||||||
|
pub fn frame_secs(&self) -> f64 {
|
||||||
|
self.line_ms * f64::from(self.height) / f64::from(self.lines_per_transmission) / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Martin — sync, porch, then green, blue, red, each followed by a separator.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MARTIN_M1: &[Segment] = &[
|
||||||
|
Segment::Sync(4.862),
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 146.432,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 146.432,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 146.432,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARTIN_M2: &[Segment] = &[
|
||||||
|
Segment::Sync(4.862),
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 73.216,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 73.216,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 73.216,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scottie — the sync pulse sits between the blue and red scans, so a line
|
||||||
|
// starts one separator before the green scan and the sync of the *previous*
|
||||||
|
// line is what marks it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SCOTTIE_S1: &[Segment] = &[
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 138.240,
|
||||||
|
},
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 138.240,
|
||||||
|
},
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 138.240,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOTTIE_S2: &[Segment] = &[
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 88.064,
|
||||||
|
},
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 88.064,
|
||||||
|
},
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 88.064,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOTTIE_DX: &[Segment] = &[
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 345.6,
|
||||||
|
},
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 345.6,
|
||||||
|
},
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 345.6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
|
||||||
|
/// 138.24 for S1, and the equivalent for the others.
|
||||||
|
const fn scottie_sync_offset(scan_ms: f64) -> f64 {
|
||||||
|
1.5 + scan_ms + 1.5 + scan_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Robot — luminance plus chrominance, the chroma scans at half the width.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROBOT_36: &[Segment] = &[
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(3.0),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaOdd,
|
||||||
|
ms: 88.0,
|
||||||
|
},
|
||||||
|
Segment::Gap(4.5),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaAlternating,
|
||||||
|
ms: 44.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROBOT_72: &[Segment] = &[
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(3.0),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaOdd,
|
||||||
|
ms: 138.0,
|
||||||
|
},
|
||||||
|
Segment::Gap(4.5),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaR,
|
||||||
|
ms: 69.0,
|
||||||
|
},
|
||||||
|
Segment::Gap(4.5),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaB,
|
||||||
|
ms: 69.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PD — one transmitted line carries two image lines: the luminance of both,
|
||||||
|
// with a single pair of chroma scans shared between them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
macro_rules! pd_segments {
|
||||||
|
($name:ident, $scan:expr) => {
|
||||||
|
const $name: &[Segment] = &[
|
||||||
|
Segment::Sync(20.0),
|
||||||
|
Segment::Gap(2.08),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaOdd,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaR,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaB,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaEven,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pd_segments!(PD_50, 91.520);
|
||||||
|
pd_segments!(PD_90, 170.240);
|
||||||
|
pd_segments!(PD_120, 121.600);
|
||||||
|
pd_segments!(PD_160, 195.584);
|
||||||
|
pd_segments!(PD_180, 183.040);
|
||||||
|
pd_segments!(PD_240, 244.672);
|
||||||
|
pd_segments!(PD_290, 228.800);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wraase SC2-180 — red, green, blue in that order after one porch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WRAASE_SC2_180: &[Segment] = &[
|
||||||
|
Segment::Sync(5.5225),
|
||||||
|
Segment::Gap(0.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 235.0,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 235.0,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 235.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every mode this decoder knows, in VIS order.
|
||||||
|
pub static MODES: &[SstvMode] = &[
|
||||||
|
SstvMode {
|
||||||
|
vis: 8,
|
||||||
|
name: "Robot 36",
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
line_ms: 150.0,
|
||||||
|
segments: ROBOT_36,
|
||||||
|
color: ColorModel::YCrCbAlternating,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 12,
|
||||||
|
name: "Robot 72",
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
line_ms: 300.0,
|
||||||
|
segments: ROBOT_72,
|
||||||
|
color: ColorModel::YCrCb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 40,
|
||||||
|
name: "Martin M2",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 226.798,
|
||||||
|
segments: MARTIN_M2,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 44,
|
||||||
|
name: "Martin M1",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 446.446,
|
||||||
|
segments: MARTIN_M1,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 55,
|
||||||
|
name: "Wraase SC2-180",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 711.0225,
|
||||||
|
segments: WRAASE_SC2_180,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 56,
|
||||||
|
name: "Scottie S2",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 277.692,
|
||||||
|
segments: SCOTTIE_S2,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: scottie_sync_offset(88.064),
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 60,
|
||||||
|
name: "Scottie S1",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 428.22,
|
||||||
|
segments: SCOTTIE_S1,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: scottie_sync_offset(138.240),
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 76,
|
||||||
|
name: "Scottie DX",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 1050.3,
|
||||||
|
segments: SCOTTIE_DX,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: scottie_sync_offset(345.6),
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 93,
|
||||||
|
name: "PD50",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 388.16,
|
||||||
|
segments: PD_50,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 94,
|
||||||
|
name: "PD290",
|
||||||
|
width: 800,
|
||||||
|
height: 616,
|
||||||
|
line_ms: 937.28,
|
||||||
|
segments: PD_290,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 95,
|
||||||
|
name: "PD120",
|
||||||
|
width: 640,
|
||||||
|
height: 496,
|
||||||
|
line_ms: 508.48,
|
||||||
|
segments: PD_120,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 96,
|
||||||
|
name: "PD180",
|
||||||
|
width: 640,
|
||||||
|
height: 496,
|
||||||
|
line_ms: 754.24,
|
||||||
|
segments: PD_180,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 97,
|
||||||
|
name: "PD240",
|
||||||
|
width: 640,
|
||||||
|
height: 496,
|
||||||
|
line_ms: 1000.768,
|
||||||
|
segments: PD_240,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 98,
|
||||||
|
name: "PD160",
|
||||||
|
width: 512,
|
||||||
|
height: 400,
|
||||||
|
line_ms: 804.416,
|
||||||
|
segments: PD_160,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 99,
|
||||||
|
name: "PD90",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 703.04,
|
||||||
|
segments: PD_90,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Look a mode up by the VIS code that announced it.
|
||||||
|
pub fn mode_for_vis(vis: u8) -> Option<&'static SstvMode> {
|
||||||
|
MODES.iter().find(|mode| mode.vis == vis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an instantaneous frequency to an 8-bit level: 1500 Hz is black, 2300 Hz
|
||||||
|
/// white. Frequencies outside the band clamp rather than wrap, so a sync pulse
|
||||||
|
/// that lands inside a scan reads as black instead of as bright noise.
|
||||||
|
pub fn level_from_hz(hz: f32) -> u8 {
|
||||||
|
let level = (hz - BLACK_HZ) * (255.0 / (WHITE_HZ - BLACK_HZ));
|
||||||
|
level.clamp(0.0, 255.0).round() as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The segment list and the published line time are two statements of the
|
||||||
|
// same fact, entered by hand from the specification. If a digit is wrong in
|
||||||
|
// one it is unlikely to be wrong identically in the other.
|
||||||
|
#[test]
|
||||||
|
fn segments_add_up_to_the_published_line_time() {
|
||||||
|
for mode in MODES {
|
||||||
|
let sum = mode.segments_ms();
|
||||||
|
assert!(
|
||||||
|
(sum - mode.line_ms).abs() < 0.001,
|
||||||
|
"{}: segments total {:.4} ms, line time says {:.4} ms",
|
||||||
|
mode.name,
|
||||||
|
sum,
|
||||||
|
mode.line_ms,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vis_codes_are_unique_and_resolvable() {
|
||||||
|
for mode in MODES {
|
||||||
|
assert_eq!(mode_for_vis(mode.vis).map(|m| m.name), Some(mode.name));
|
||||||
|
}
|
||||||
|
let mut codes: Vec<u8> = MODES.iter().map(|m| m.vis).collect();
|
||||||
|
codes.sort_unstable();
|
||||||
|
let count = codes.len();
|
||||||
|
codes.dedup();
|
||||||
|
assert_eq!(codes.len(), count, "two modes claim the same VIS code");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_mode_scans_enough_channels_for_its_colour_model() {
|
||||||
|
for mode in MODES {
|
||||||
|
let scans = mode.scans();
|
||||||
|
let expected = match mode.color {
|
||||||
|
ColorModel::Rgb => 3,
|
||||||
|
ColorModel::YCrCbAlternating => 2,
|
||||||
|
ColorModel::YCrCb => 3,
|
||||||
|
ColorModel::YCrCbPaired => 4,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
scans.len(),
|
||||||
|
expected,
|
||||||
|
"{} has {} scans",
|
||||||
|
mode.name,
|
||||||
|
scans.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame durations are what operators know these modes by — the number in
|
||||||
|
// the name is the number of seconds.
|
||||||
|
#[test]
|
||||||
|
fn frame_durations_match_the_names() {
|
||||||
|
for (vis, secs) in [(8u8, 36.0), (12, 72.0), (93, 50.0), (99, 90.0), (95, 126.0)] {
|
||||||
|
let mode = mode_for_vis(vis).expect("mode in table");
|
||||||
|
let actual = mode.frame_secs();
|
||||||
|
assert!(
|
||||||
|
(actual - secs).abs() < 1.5,
|
||||||
|
"{} takes {:.1} s, expected about {:.0} s",
|
||||||
|
mode.name,
|
||||||
|
actual,
|
||||||
|
secs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levels_span_black_to_white_and_clamp_outside() {
|
||||||
|
assert_eq!(level_from_hz(BLACK_HZ), 0);
|
||||||
|
assert_eq!(level_from_hz(WHITE_HZ), 255);
|
||||||
|
assert_eq!(level_from_hz(1900.0), 128);
|
||||||
|
assert_eq!(level_from_hz(SYNC_HZ), 0, "a sync pulse must read as black");
|
||||||
|
assert_eq!(level_from_hz(3000.0), 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! VIS header detection.
|
||||||
|
//!
|
||||||
|
//! Every transmission announces its mode in a fixed preamble:
|
||||||
|
//!
|
||||||
|
//! | Part | Tone | Duration |
|
||||||
|
//! |------|------|----------|
|
||||||
|
//! | Leader | 1900 Hz | 300 ms |
|
||||||
|
//! | Break | 1200 Hz | 10 ms |
|
||||||
|
//! | Leader | 1900 Hz | 300 ms |
|
||||||
|
//! | Start bit | 1200 Hz | 30 ms |
|
||||||
|
//! | 7 data bits, LSB first | 1100 Hz = 1, 1300 Hz = 0 | 30 ms each |
|
||||||
|
//! | Even parity | as above | 30 ms |
|
||||||
|
//! | Stop bit | 1200 Hz | 30 ms |
|
||||||
|
//!
|
||||||
|
//! The detector looks for the start bit standing behind a leader, reads the
|
||||||
|
//! eight bits that follow, and checks the parity. Parity is the only integrity
|
||||||
|
//! check the header has, so a code that fails it is discarded rather than
|
||||||
|
//! guessed at — decoding 114 seconds of Martin M1 as Scottie DX produces a
|
||||||
|
//! convincing-looking image of nothing.
|
||||||
|
|
||||||
|
/// Tone durations, in milliseconds.
|
||||||
|
const BIT_MS: f64 = 30.0;
|
||||||
|
const LEADER_MS: f64 = 300.0;
|
||||||
|
|
||||||
|
/// How far a tone may sit from its nominal frequency and still be recognised.
|
||||||
|
/// Wide enough for a rig tuned a little off, narrow enough that 1100, 1200,
|
||||||
|
/// 1300 and 1900 Hz stay distinct.
|
||||||
|
const TONE_TOLERANCE_HZ: f32 = 60.0;
|
||||||
|
|
||||||
|
const LEADER_HZ: f32 = 1900.0;
|
||||||
|
const START_HZ: f32 = 1200.0;
|
||||||
|
const ONE_HZ: f32 = 1100.0;
|
||||||
|
const ZERO_HZ: f32 = 1300.0;
|
||||||
|
|
||||||
|
/// A VIS header found in the stream.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct VisHit {
|
||||||
|
/// The code, which names the mode.
|
||||||
|
pub code: u8,
|
||||||
|
/// Index just past the stop bit: where the image itself begins.
|
||||||
|
pub image_start: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn near(freq: f32, target: f32) -> bool {
|
||||||
|
(freq - target).abs() <= TONE_TOLERANCE_HZ
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean frequency over the middle 60% of a bit cell, which keeps the filter's
|
||||||
|
/// transitions at either edge out of the measurement.
|
||||||
|
fn bit_frequency(freqs: &[f32], start: f64, samples_per_bit: f64) -> Option<f32> {
|
||||||
|
let from = (start + samples_per_bit * 0.2).round() as usize;
|
||||||
|
let to = (start + samples_per_bit * 0.8).round() as usize;
|
||||||
|
if to <= from || to > freqs.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let window = &freqs[from..to];
|
||||||
|
Some(window.iter().sum::<f32>() / window.len() as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search `freqs` for a VIS header, starting at `from`.
|
||||||
|
///
|
||||||
|
/// Returns the first header whose parity checks out. `freqs` is instantaneous
|
||||||
|
/// frequency in Hz, one entry per audio sample.
|
||||||
|
pub fn find_vis(freqs: &[f32], sample_rate: u32, from: usize) -> Option<VisHit> {
|
||||||
|
let sr = f64::from(sample_rate);
|
||||||
|
// Header tones are 10 ms at the shortest, so a millisecond of averaging
|
||||||
|
// costs nothing and takes the demodulator's ripple — ±95 Hz at 1200 Hz —
|
||||||
|
// out of tones that are 100 Hz apart.
|
||||||
|
let smoothed = crate::demod::smooth(freqs, (sr / 1000.0).round() as usize);
|
||||||
|
let freqs = smoothed.as_slice();
|
||||||
|
let samples_per_bit = BIT_MS / 1000.0 * sr;
|
||||||
|
let leader_samples = (LEADER_MS / 1000.0 * sr) as usize;
|
||||||
|
// The start bit must be at least this long to be one.
|
||||||
|
let min_start_run = (samples_per_bit * 0.7) as usize;
|
||||||
|
|
||||||
|
// A leader has to precede the start bit. Half of one is enough evidence,
|
||||||
|
// and asking for less than the full 300 ms means the search still works on
|
||||||
|
// a buffer that begins part-way through the header.
|
||||||
|
let leader_needed = leader_samples / 2;
|
||||||
|
|
||||||
|
// The bits themselves may run to the very end of what has arrived so far;
|
||||||
|
// reading them is what decides whether there is enough, not this bound.
|
||||||
|
let mut i = from.max(leader_needed);
|
||||||
|
while i < freqs.len() {
|
||||||
|
if !near(freqs[i], START_HZ) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Measure the run of start tone.
|
||||||
|
let mut run = 0usize;
|
||||||
|
while i + run < freqs.len() && near(freqs[i + run], START_HZ) {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
if run < min_start_run {
|
||||||
|
i += run.max(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What came before it: the leader. Sampled rather than scanned in
|
||||||
|
// full, since only its identity matters, not its exact length.
|
||||||
|
let leader_from = i - leader_needed;
|
||||||
|
let leader_hits = freqs[leader_from..i]
|
||||||
|
.iter()
|
||||||
|
.step_by(16)
|
||||||
|
.filter(|&&f| near(f, LEADER_HZ))
|
||||||
|
.count();
|
||||||
|
let leader_total = freqs[leader_from..i].iter().step_by(16).count();
|
||||||
|
if leader_total == 0 || (leader_hits as f64) < 0.7 * leader_total as f64 {
|
||||||
|
i += run;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bits follow the start bit, which the run just measured. Use the run's
|
||||||
|
// own end rather than a nominal offset, so a start bit stretched or
|
||||||
|
// clipped by the filter does not shift every bit after it.
|
||||||
|
let bits_start = (i + run) as f64;
|
||||||
|
let mut bits = [false; 8];
|
||||||
|
let mut readable = true;
|
||||||
|
for (index, bit) in bits.iter_mut().enumerate() {
|
||||||
|
let at = bits_start + samples_per_bit * index as f64;
|
||||||
|
match bit_frequency(freqs, at, samples_per_bit) {
|
||||||
|
Some(freq) if near(freq, ONE_HZ) => *bit = true,
|
||||||
|
Some(freq) if near(freq, ZERO_HZ) => *bit = false,
|
||||||
|
_ => {
|
||||||
|
readable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !readable {
|
||||||
|
i += run;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seven data bits, LSB first, then even parity over them.
|
||||||
|
let code = bits[..7]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(0u8, |acc, (index, &set)| acc | (u8::from(set) << index));
|
||||||
|
let ones = bits[..7].iter().filter(|&&b| b).count() + usize::from(bits[7]);
|
||||||
|
if ones % 2 != 0 {
|
||||||
|
i += run;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the stop bit is the image.
|
||||||
|
let image_start = (bits_start + samples_per_bit * 9.0).round() as usize;
|
||||||
|
return Some(VisHit { code, image_start });
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::encode::{vis_tones, Tone};
|
||||||
|
|
||||||
|
fn freqs_from_tones(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for tone in tones {
|
||||||
|
let samples = (tone.ms / 1000.0 * f64::from(sample_rate)).round() as usize;
|
||||||
|
out.extend(std::iter::repeat_n(tone.hz, samples));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_every_code_the_mode_table_knows() {
|
||||||
|
for mode in crate::mode::MODES {
|
||||||
|
let freqs = freqs_from_tones(&vis_tones(mode.vis), 48_000);
|
||||||
|
let hit = find_vis(&freqs, 48_000, 0)
|
||||||
|
.unwrap_or_else(|| panic!("{} header not found", mode.name));
|
||||||
|
assert_eq!(
|
||||||
|
hit.code, mode.vis,
|
||||||
|
"{} decoded as VIS {}",
|
||||||
|
mode.name, hit.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_image_starts_after_the_stop_bit() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let freqs = freqs_from_tones(&vis_tones(44), sample_rate);
|
||||||
|
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||||
|
// Header is 300 + 10 + 300 ms of leader and break, then ten 30 ms bits.
|
||||||
|
let expected = ((300.0 + 10.0 + 300.0 + 300.0) / 1000.0 * f64::from(sample_rate)) as usize;
|
||||||
|
let slack = sample_rate as usize / 100; // 10 ms
|
||||||
|
assert!(
|
||||||
|
hit.image_start.abs_diff(expected) < slack,
|
||||||
|
"image starts at {}, expected about {expected}",
|
||||||
|
hit.image_start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_with_broken_parity_is_not_a_header() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut tones = vis_tones(44);
|
||||||
|
// Flip the parity bit alone: seven data bits still say Martin M1, but
|
||||||
|
// nothing now vouches for them.
|
||||||
|
let parity = tones.len() - 2;
|
||||||
|
tones[parity].hz = if tones[parity].hz == ONE_HZ {
|
||||||
|
ZERO_HZ
|
||||||
|
} else {
|
||||||
|
ONE_HZ
|
||||||
|
};
|
||||||
|
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||||
|
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tones_without_a_leader_are_not_a_header() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut tones = vis_tones(44);
|
||||||
|
// Same bits, but the leader before them is a pixel-band tone — which is
|
||||||
|
// what a passing image looks like.
|
||||||
|
tones[0].hz = 2000.0;
|
||||||
|
tones[2].hz = 2000.0;
|
||||||
|
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||||
|
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_part_way_into_the_buffer_is_still_found() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut freqs = vec![1750.0f32; sample_rate as usize]; // a second of picture
|
||||||
|
freqs.extend(freqs_from_tones(&vis_tones(60), sample_rate));
|
||||||
|
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||||
|
assert_eq!(hit.code, 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Decode what was encoded, and compare the pictures.
|
||||||
|
//!
|
||||||
|
//! A decoder for a picture format can only really be tested against a picture.
|
||||||
|
//! These tests build a test card, transmit it in each mode through the
|
||||||
|
//! encoder, and hold the decoder to what comes back — pixel by pixel, with a
|
||||||
|
//! tolerance that accounts for the round trip through frequency and, for the
|
||||||
|
//! colour modes, through a chroma channel at half the width.
|
||||||
|
//!
|
||||||
|
//! What this does not test is the timing table itself: the encoder reads the
|
||||||
|
//! same numbers as the decoder, so a wrong line duration would cancel out.
|
||||||
|
//! That is checked in `mode.rs` against the published line times instead.
|
||||||
|
|
||||||
|
use trx_sstv::encode::{encode, Frame};
|
||||||
|
use trx_sstv::mode::mode_for_vis;
|
||||||
|
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent, SstvImage};
|
||||||
|
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
|
||||||
|
/// A test card with something for every part of the decoder to get wrong:
|
||||||
|
/// vertical colour bars catch channels swapped or shifted, the horizontal
|
||||||
|
/// gradient catches a line-timing drift, and the corner blocks catch a picture
|
||||||
|
/// that arrives upside down or mirrored.
|
||||||
|
fn test_card(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut rgb = vec![0u8; width * height * 3];
|
||||||
|
let bars: [(u8, u8, u8); 8] = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
];
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let at = (y * width + x) * 3;
|
||||||
|
let (r, g, b) = if y < height / 2 {
|
||||||
|
bars[x * bars.len() / width]
|
||||||
|
} else {
|
||||||
|
let ramp = (x * 255 / width.max(1)) as u8;
|
||||||
|
let down = (y * 255 / height.max(1)) as u8;
|
||||||
|
(ramp, down, 255 - ramp)
|
||||||
|
};
|
||||||
|
rgb[at] = r;
|
||||||
|
rgb[at + 1] = g;
|
||||||
|
rgb[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Corner marks: red top-left, blue bottom-right.
|
||||||
|
for y in 0..height.min(8) {
|
||||||
|
for x in 0..width.min(8) {
|
||||||
|
let at = (y * width + x) * 3;
|
||||||
|
rgb[at] = 255;
|
||||||
|
rgb[at + 1] = 0;
|
||||||
|
rgb[at + 2] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y in height.saturating_sub(8)..height {
|
||||||
|
for x in width.saturating_sub(8)..width {
|
||||||
|
let at = (y * width + x) * 3;
|
||||||
|
rgb[at] = 0;
|
||||||
|
rgb[at + 1] = 0;
|
||||||
|
rgb[at + 2] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rgb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run audio through the decoder in blocks the size a sound card delivers.
|
||||||
|
///
|
||||||
|
/// A little silence is fed after the signal, as a receiver that keeps
|
||||||
|
/// listening supplies: the demodulator is a filter, so the last millisecond of
|
||||||
|
/// any transmission needs the samples after it before it can be read.
|
||||||
|
fn decode(audio: &[f32]) -> (Vec<SstvImage>, usize) {
|
||||||
|
let mut tail = audio.to_vec();
|
||||||
|
tail.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 20));
|
||||||
|
let audio = tail.as_slice();
|
||||||
|
|
||||||
|
let mut decoder = SstvDecoder::new(SAMPLE_RATE, SstvConfig::default());
|
||||||
|
let mut images = Vec::new();
|
||||||
|
let mut rows = 0;
|
||||||
|
for block in audio.chunks(1024) {
|
||||||
|
for event in decoder.process_samples(block) {
|
||||||
|
match event {
|
||||||
|
SstvEvent::Row { .. } => rows += 1,
|
||||||
|
SstvEvent::Complete(image) => images.push(image),
|
||||||
|
SstvEvent::Started { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(images, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean absolute error per colour channel between two same-sized images.
|
||||||
|
fn mean_error(a: &[u8], b: &[u8]) -> f64 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let total: u64 = a
|
||||||
|
.iter()
|
||||||
|
.zip(b)
|
||||||
|
.map(|(x, y)| u64::from(x.abs_diff(*y)))
|
||||||
|
.sum();
|
||||||
|
total as f64 / a.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error over the part of the picture away from channel edges, where a decoder
|
||||||
|
/// that is a pixel out on a hard colour boundary would otherwise dominate.
|
||||||
|
fn interior_error(mode_width: usize, height: usize, sent: &[u8], got: &[u8]) -> f64 {
|
||||||
|
let mut total = 0u64;
|
||||||
|
let mut count = 0u64;
|
||||||
|
for y in 2..height.saturating_sub(2) {
|
||||||
|
for x in 4..mode_width.saturating_sub(4) {
|
||||||
|
// Skip the columns where the bars change, which is where a
|
||||||
|
// half-pixel timing difference shows up as a whole-colour error.
|
||||||
|
if x % (mode_width / 8) < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let at = (y * mode_width + x) * 3;
|
||||||
|
for channel in 0..3 {
|
||||||
|
total += u64::from(sent[at + channel].abs_diff(got[at + channel]));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total as f64 / count.max(1) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round_trip(vis: u8, tolerance: f64) {
|
||||||
|
let mode = mode_for_vis(vis).expect("mode in table");
|
||||||
|
let width = usize::from(mode.width);
|
||||||
|
let height = usize::from(mode.height);
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let audio = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
|
||||||
|
let (images, rows) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
1,
|
||||||
|
"{}: expected one picture, got {}",
|
||||||
|
mode.name,
|
||||||
|
images.len()
|
||||||
|
);
|
||||||
|
let image = &images[0];
|
||||||
|
assert!(
|
||||||
|
image.complete,
|
||||||
|
"{}: reception did not reach the bottom",
|
||||||
|
mode.name
|
||||||
|
);
|
||||||
|
assert_eq!(image.mode, mode.name);
|
||||||
|
assert_eq!(
|
||||||
|
image.lines, mode.height,
|
||||||
|
"{}: {} of {} lines",
|
||||||
|
mode.name, image.lines, mode.height
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows, height,
|
||||||
|
"{}: emitted {rows} rows for {height} lines",
|
||||||
|
mode.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = interior_error(width, height, &sent, &image.rgb);
|
||||||
|
assert!(
|
||||||
|
error < tolerance,
|
||||||
|
"{}: mean error {error:.1} levels, tolerance {tolerance:.1}",
|
||||||
|
mode.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn martin_m1_round_trips() {
|
||||||
|
round_trip(44, 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn martin_m2_round_trips() {
|
||||||
|
round_trip(40, 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scottie_s1_round_trips() {
|
||||||
|
round_trip(60, 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scottie_s2_round_trips() {
|
||||||
|
round_trip(56, 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraase_sc2_180_round_trips() {
|
||||||
|
round_trip(55, 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The colour-difference modes lose chroma resolution by design, so the bars
|
||||||
|
// bleed into one another at their edges; the tolerance is on the interior.
|
||||||
|
#[test]
|
||||||
|
fn robot_72_round_trips() {
|
||||||
|
round_trip(12, 14.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn robot_36_round_trips() {
|
||||||
|
// One chroma channel per line, the other carried over from the line
|
||||||
|
// before, so alternate lines are a line stale in one channel.
|
||||||
|
round_trip(8, 26.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pd90_round_trips() {
|
||||||
|
round_trip(99, 14.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pd120_round_trips() {
|
||||||
|
round_trip(95, 14.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silence before and after is the normal case — a receiver is not started at
|
||||||
|
/// the instant the transmission does.
|
||||||
|
#[test]
|
||||||
|
fn survives_silence_around_the_transmission() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let mut audio = vec![0.0f32; SAMPLE_RATE as usize * 2];
|
||||||
|
audio.extend(encode(mode, &frame, SAMPLE_RATE));
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize));
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
1,
|
||||||
|
"expected one picture from a transmission in silence"
|
||||||
|
);
|
||||||
|
assert!(images[0].complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transmission cut off part-way is what a fade or a shut-down transmitter
|
||||||
|
/// produces. The lines that did arrive are worth keeping.
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_transmission_still_yields_its_lines() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let full = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
// Two thirds of the picture, then silence for long enough that the decoder
|
||||||
|
// stops waiting for the rest.
|
||||||
|
let mut audio = full[..full.len() * 2 / 3].to_vec();
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize * 5));
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
1,
|
||||||
|
"a cut-off transmission produced no picture"
|
||||||
|
);
|
||||||
|
let image = &images[0];
|
||||||
|
assert!(
|
||||||
|
!image.complete,
|
||||||
|
"a two-thirds transmission reported as complete"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
image.lines > mode.height / 2 && image.lines < mode.height,
|
||||||
|
"{} lines of {} arrived",
|
||||||
|
image.lines,
|
||||||
|
mode.height,
|
||||||
|
);
|
||||||
|
// What did arrive is the top of the picture, and it is right.
|
||||||
|
let rows = usize::from(image.lines).saturating_sub(4);
|
||||||
|
let error = mean_error(&sent[..width * rows * 3], &image.rgb[..width * rows * 3]);
|
||||||
|
assert!(
|
||||||
|
error < 12.0,
|
||||||
|
"the lines that arrived are wrong: mean error {error:.1}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two pictures back to back: the decoder has to finish the first and pick up
|
||||||
|
/// the header of the second.
|
||||||
|
#[test]
|
||||||
|
fn decodes_a_second_transmission_after_the_first() {
|
||||||
|
let mode = mode_for_vis(40).expect("Martin M2");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let one = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
|
||||||
|
let mut audio = one.clone();
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 2));
|
||||||
|
audio.extend(one);
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
2,
|
||||||
|
"expected two pictures, got {}",
|
||||||
|
images.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
images.iter().all(|image| image.complete),
|
||||||
|
"a picture did not finish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noise on the signal is the normal condition on HF. The picture should
|
||||||
|
/// degrade, not fall apart.
|
||||||
|
#[test]
|
||||||
|
fn decodes_through_noise() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let clean = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
|
||||||
|
// Deterministic pseudo-noise at about 20 dB below the signal.
|
||||||
|
let mut seed = 0x5eed_1234u32;
|
||||||
|
let noisy: Vec<f32> = clean
|
||||||
|
.iter()
|
||||||
|
.map(|sample| {
|
||||||
|
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
let noise = (seed >> 8) as f32 / f32::from(u16::MAX) / 256.0 - 0.5;
|
||||||
|
sample + noise * 0.2
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (images, _) = decode(&noisy);
|
||||||
|
assert_eq!(images.len(), 1, "noise cost the whole picture");
|
||||||
|
let image = &images[0];
|
||||||
|
assert!(image.complete, "noise cost the bottom of the picture");
|
||||||
|
let error = interior_error(width, height, &sent, &image.rgb);
|
||||||
|
assert!(error < 20.0, "mean error through noise {error:.1} levels");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sound card that plays the signal and the one that records it never
|
||||||
|
/// agree exactly. A part-per-thousand error is far worse than reality and the
|
||||||
|
/// picture should still stand up.
|
||||||
|
#[test]
|
||||||
|
fn tolerates_a_transmitter_clock_that_runs_fast() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
// Encoding at a slightly different rate and decoding at 48 kHz is exactly
|
||||||
|
// a clock error: every duration is stretched by the same factor.
|
||||||
|
let audio = encode(mode, &frame, 48_048);
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(images.len(), 1, "a 0.1% clock error cost the picture");
|
||||||
|
let error = interior_error(width, height, &sent, &images[0].rgb);
|
||||||
|
assert!(
|
||||||
|
error < 12.0,
|
||||||
|
"mean error with a fast clock {error:.1} levels"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,11 +27,11 @@ use trx_core::audio::{
|
|||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||||
AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
use trx_frontend::VChanAudioCmd;
|
use trx_frontend::VChanAudioCmd;
|
||||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
|||||||
| AUDIO_MSG_LRPT_IMAGE
|
| AUDIO_MSG_LRPT_IMAGE
|
||||||
| AUDIO_MSG_LRPT_PROGRESS
|
| AUDIO_MSG_LRPT_PROGRESS
|
||||||
| AUDIO_MSG_WEFAX_DECODE
|
| AUDIO_MSG_WEFAX_DECODE
|
||||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
| AUDIO_MSG_WEFAX_PROGRESS
|
||||||
|
| AUDIO_MSG_SSTV_DECODE
|
||||||
|
| AUDIO_MSG_SSTV_PROGRESS,
|
||||||
payload,
|
payload,
|
||||||
)) => {
|
)) => {
|
||||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
DecodedMessage::LrptProgress(_) => {}
|
DecodedMessage::LrptProgress(_) => {}
|
||||||
DecodedMessage::Wefax(_) => {}
|
DecodedMessage::Wefax(_) => {}
|
||||||
DecodedMessage::WefaxProgress(_) => {}
|
DecodedMessage::WefaxProgress(_) => {}
|
||||||
|
// Pictures replay from their own history, not this one.
|
||||||
|
DecodedMessage::Sstv(_) => {}
|
||||||
|
DecodedMessage::SstvProgress(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||||
WsprMessage,
|
WefaxMessage, WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||||
use trx_core::{DynResult, RigRequest, RigState};
|
use trx_core::{DynResult, RigRequest, RigState};
|
||||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
|||||||
pub ft2: DecodeHistory<Ft8Message>,
|
pub ft2: DecodeHistory<Ft8Message>,
|
||||||
pub wspr: DecodeHistory<WsprMessage>,
|
pub wspr: DecodeHistory<WsprMessage>,
|
||||||
pub wefax: DecodeHistory<WefaxMessage>,
|
pub wefax: DecodeHistory<WefaxMessage>,
|
||||||
|
pub sstv: DecodeHistory<SstvMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DecodeHistoryContext {
|
impl Default for DecodeHistoryContext {
|
||||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
|||||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,8 +160,18 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function aisSummaryText(msg) {
|
||||||
|
const parts = [];
|
||||||
|
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
if (motion) parts.push(motion);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
if (route && parts.length < 2) parts.push(route);
|
||||||
|
if (!parts.length) return route || "no position reported";
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
function renderAisRow(msg) {
|
function renderAisRow(msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("details");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -174,7 +184,8 @@ function renderAisRow(msg) {
|
|||||||
const motion = aisMotionText(msg);
|
const motion = aisMotionText(msg);
|
||||||
const route = aisRouteText(msg);
|
const route = aisRouteText(msg);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
|
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
|
||||||
|
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
row.dataset.filterText = [
|
row.dataset.filterText = [
|
||||||
name,
|
name,
|
||||||
msg.mmsi,
|
msg.mmsi,
|
||||||
@@ -185,7 +196,16 @@ function renderAisRow(msg) {
|
|||||||
msg.destination,
|
msg.destination,
|
||||||
aisTypeLabel(msg.message_type)
|
aisTypeLabel(msg.message_type)
|
||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
|
||||||
|
row.querySelectorAll("[data-ais-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
|
||||||
|
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
aisWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
});
|
||||||
|
});
|
||||||
applyAisFilterToRow(row);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -265,9 +285,11 @@ function addAisMessage(msg) {
|
|||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(msg);
|
||||||
aisWindow.aisMapAddVessel(msg);
|
}
|
||||||
}
|
function plotAisMessage(msg) {
|
||||||
|
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
function normalizeServerAisMessage(msg) {
|
function normalizeServerAisMessage(msg) {
|
||||||
return {
|
return {
|
||||||
@@ -288,9 +310,7 @@ function onServerAisBatch(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(next);
|
||||||
aisWindow.aisMapAddVessel(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -332,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerAisBatch,
|
onBatch: onServerAisBatch,
|
||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView
|
prune: pruneAisHistoryView,
|
||||||
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -747,7 +747,12 @@ function elementById(id) {
|
|||||||
api.applyLayout(savedLayoutName(), { persist: false });
|
api.applyLayout(savedLayoutName(), { persist: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
const overflowOrder = [
|
||||||
|
".operator-layout-picker",
|
||||||
|
".header-style-pick",
|
||||||
|
"#header-share-btn",
|
||||||
|
"#theme-toggle"
|
||||||
|
];
|
||||||
function anchorMenu(button, menu) {
|
function anchorMenu(button, menu) {
|
||||||
const rect = button.getBoundingClientRect();
|
const rect = button.getBoundingClientRect();
|
||||||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||||||
@@ -880,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");
|
||||||
@@ -927,7 +932,7 @@ function elementById(id) {
|
|||||||
["Overview", ["overview"]],
|
["Overview", ["overview"]],
|
||||||
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
||||||
["Broadcast & images", ["rds", "sat", "wefax"]]
|
["Broadcast & images", ["rds", "sat", "wefax", "sstv"]]
|
||||||
];
|
];
|
||||||
groups.forEach(([label, ids]) => {
|
groups.forEach(([label, ids]) => {
|
||||||
const group = document.createElement("optgroup");
|
const group = document.createElement("optgroup");
|
||||||
@@ -1060,6 +1065,9 @@ var runtime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
syncMapAll() {
|
||||||
|
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||||||
|
},
|
||||||
clearQueued() {
|
clearQueued() {
|
||||||
queued.clear();
|
queued.clear();
|
||||||
},
|
},
|
||||||
@@ -1521,6 +1529,7 @@ var TAB_ORDER = [
|
|||||||
"bookmarks",
|
"bookmarks",
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
|
"satellites",
|
||||||
"statistics",
|
"statistics",
|
||||||
"recorder",
|
"recorder",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1531,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",
|
||||||
@@ -1552,6 +1562,44 @@ function updateTabHistory(name, replace = false) {
|
|||||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||||
else window.history.pushState({}, "", nextUrl);
|
else window.history.pushState({}, "", nextUrl);
|
||||||
}
|
}
|
||||||
|
var FREQ_MULTIPLIER = { k: 1e3, m: 1e6, g: 1e9 };
|
||||||
|
function parseFrequencyParam(raw) {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||||
|
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
function parseModeParam(raw) {
|
||||||
|
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||||
|
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||||
|
}
|
||||||
|
function parseTuneLink(search) {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const rig = (params.get("rig") || "").trim();
|
||||||
|
return {
|
||||||
|
rig: rig || null,
|
||||||
|
freqHz: parseFrequencyParam(params.get("f")),
|
||||||
|
mode: parseModeParam(params.get("mode")),
|
||||||
|
bandwidthHz: parseFrequencyParam(params.get("bw"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function tuneLinkSearch(search, link) {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const set = (key, value) => {
|
||||||
|
if (value == null || value === "") params.delete(key);
|
||||||
|
else params.set(key, value);
|
||||||
|
};
|
||||||
|
const hz = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null;
|
||||||
|
set("rig", link.rig);
|
||||||
|
set("f", hz(link.freqHz));
|
||||||
|
set("mode", parseModeParam(link.mode));
|
||||||
|
set("bw", hz(link.bandwidthHz));
|
||||||
|
const text = params.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
// src/features/radio/auto-bandwidth.ts
|
// src/features/radio/auto-bandwidth.ts
|
||||||
function clampPercent(value) {
|
function clampPercent(value) {
|
||||||
@@ -1689,9 +1737,28 @@ function estimateNoiseFloorDb(bins) {
|
|||||||
|
|
||||||
// src/plugin-loader.ts
|
// src/plugin-loader.ts
|
||||||
var pluginGroups = {
|
var pluginGroups = {
|
||||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
|
// with it. They used to come only with the map group, which left their
|
||||||
|
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||||
|
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||||
|
"digital-modes": [
|
||||||
|
"/ft8.js",
|
||||||
|
"/ft4.js",
|
||||||
|
"/ft2.js",
|
||||||
|
"/wspr.js",
|
||||||
|
"/cw.js",
|
||||||
|
"/background-decode.js",
|
||||||
|
"/sat.js",
|
||||||
|
"/wefax.js",
|
||||||
|
"/sstv.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-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: [],
|
||||||
@@ -1958,6 +2025,7 @@ function applyAuthRestrictions() {
|
|||||||
function applyCapabilities(caps) {
|
function applyCapabilities(caps) {
|
||||||
if (!caps) return;
|
if (!caps) return;
|
||||||
lastHasTx = !!caps.tx;
|
lastHasTx = !!caps.tx;
|
||||||
|
bandwidthControlSupported = !!caps.filter_controls;
|
||||||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||||||
const pttBtn2 = document.getElementById("ptt-btn");
|
const pttBtn2 = document.getElementById("ptt-btn");
|
||||||
const txPowerCol = document.getElementById("tx-power-col");
|
const txPowerCol = document.getElementById("tx-power-col");
|
||||||
@@ -2048,6 +2116,7 @@ var loadingSub = requiredElement("loading-sub");
|
|||||||
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
|
var decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||||
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||||
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||||
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||||
@@ -2175,7 +2244,7 @@ function currentDecodeHistoryRetentionMs() {
|
|||||||
}
|
}
|
||||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||||
window.applyDecodeHistoryRetention = function() {
|
window.applyDecodeHistoryRetention = function() {
|
||||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||||
window.trxPluginRuntime.prune(decoder);
|
window.trxPluginRuntime.prune(decoder);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2197,10 +2266,19 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var overviewDrawPending = false;
|
var overviewDrawPending = false;
|
||||||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
|
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
|
||||||
if (!decodeHistoryOverlayEl) return;
|
if (!decodeHistoryOverlayEl) return;
|
||||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||||
|
if (decodeHistoryProgressBarEl) {
|
||||||
|
if (fraction == null) {
|
||||||
|
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||||||
|
decodeHistoryProgressBarEl.style.width = "";
|
||||||
|
} else {
|
||||||
|
delete decodeHistoryOverlayEl.dataset.phase;
|
||||||
|
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
}
|
}
|
||||||
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
||||||
@@ -2295,6 +2373,7 @@ var jogAngle = 0;
|
|||||||
var lastClientCount = null;
|
var lastClientCount = null;
|
||||||
var lastLocked = false;
|
var lastLocked = false;
|
||||||
var sdrSquelchSupported = false;
|
var sdrSquelchSupported = false;
|
||||||
|
var bandwidthControlSupported = false;
|
||||||
var previousTuneState = null;
|
var previousTuneState = null;
|
||||||
function savePreviousTuneState() {
|
function savePreviousTuneState() {
|
||||||
previousTuneState = {
|
previousTuneState = {
|
||||||
@@ -2798,7 +2877,7 @@ function updateRigSubtitle(activeRigId) {
|
|||||||
rigSubtitle.textContent = `Rig: ${name}`;
|
rigSubtitle.textContent = `Rig: ${name}`;
|
||||||
updateDocumentTitle(activeChannelRds());
|
updateDocumentTitle(activeChannelRds());
|
||||||
}
|
}
|
||||||
function applyRigList(activeRigId, rigIds, displayNames = {}) {
|
function applyRigList(activeRigId, rigIds, displayNames) {
|
||||||
if (!Array.isArray(rigIds)) return;
|
if (!Array.isArray(rigIds)) return;
|
||||||
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
|
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
|
||||||
const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||||
@@ -4380,6 +4459,85 @@ window.buildAisVesselUrl = function(mmsi) {
|
|||||||
if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null;
|
if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null;
|
||||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||||
};
|
};
|
||||||
|
var incomingTuneLink = parseTuneLink(window.location.search);
|
||||||
|
var tuneLinkPhase = "pending";
|
||||||
|
var tuneLinkSyncTimer = null;
|
||||||
|
function currentTuneLink() {
|
||||||
|
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0 ? currentBandwidthHz : null;
|
||||||
|
return {
|
||||||
|
rig: lastActiveRigId || null,
|
||||||
|
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||||||
|
mode: lastModeName || null,
|
||||||
|
bandwidthHz
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function tuneLinkUrl() {
|
||||||
|
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||||
|
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||||||
|
}
|
||||||
|
function scheduleTuneLinkSync() {
|
||||||
|
if (tuneLinkPhase !== "live") return;
|
||||||
|
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||||||
|
if (tuneLinkSyncTimer != null) return;
|
||||||
|
tuneLinkSyncTimer = window.setTimeout(() => {
|
||||||
|
tuneLinkSyncTimer = null;
|
||||||
|
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||||
|
if (search === window.location.search) return;
|
||||||
|
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
async function applyTuneLink(link) {
|
||||||
|
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||||
|
if (!wanted) return;
|
||||||
|
if (authRole === "rx") {
|
||||||
|
showHint("Read-only session — link not applied", 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||||||
|
headerRigSwitchSelect.value = link.rig;
|
||||||
|
await switchRigFromSelect(headerRigSwitchSelect);
|
||||||
|
}
|
||||||
|
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||||||
|
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||||||
|
if (known) {
|
||||||
|
modeEl.value = link.mode;
|
||||||
|
await applyModeFromPicker();
|
||||||
|
} else {
|
||||||
|
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (link.freqHz != null) {
|
||||||
|
try {
|
||||||
|
setRigFrequency(link.freqHz);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||||||
|
spectrumBwInput.value = String(link.bandwidthHz / 1e3);
|
||||||
|
await applyBandwidthFromInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function followTuneLinkOnce() {
|
||||||
|
if (tuneLinkPhase !== "pending") return;
|
||||||
|
tuneLinkPhase = "applying";
|
||||||
|
void applyTuneLink(incomingTuneLink).finally(() => {
|
||||||
|
tuneLinkPhase = "live";
|
||||||
|
scheduleTuneLinkSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyTuneLink() {
|
||||||
|
const url = tuneLinkUrl();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showHint("Link copied", 1500);
|
||||||
|
} catch {
|
||||||
|
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var shareLinkBtn = document.getElementById("header-share-btn");
|
||||||
|
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => {
|
||||||
|
void copyTuneLink();
|
||||||
|
});
|
||||||
function render(update) {
|
function render(update) {
|
||||||
if (!update) return;
|
if (!update) return;
|
||||||
if (update.server_version) serverVersion = update.server_version;
|
if (update.server_version) serverVersion = update.server_version;
|
||||||
@@ -4666,6 +4824,9 @@ function render(update) {
|
|||||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||||
}
|
}
|
||||||
|
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||||
|
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||||
|
}
|
||||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||||
}
|
}
|
||||||
@@ -4892,6 +5053,8 @@ function render(update) {
|
|||||||
swrBar.style.width = "0%";
|
swrBar.style.width = "0%";
|
||||||
swrValue.textContent = "SWR --";
|
swrValue.textContent = "SWR --";
|
||||||
}
|
}
|
||||||
|
if (update.status) followTuneLinkOnce();
|
||||||
|
scheduleTuneLinkSync();
|
||||||
}
|
}
|
||||||
function scheduleReconnect(delayMs = 1e3) {
|
function scheduleReconnect(delayMs = 1e3) {
|
||||||
if (reconnectTimer) return;
|
if (reconnectTimer) return;
|
||||||
@@ -5684,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);
|
||||||
@@ -5713,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") {
|
||||||
@@ -6160,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);
|
||||||
@@ -6406,6 +6570,7 @@ function positionSquelchLine() {
|
|||||||
}
|
}
|
||||||
function submitSdrSquelch() {
|
function submitSdrSquelch() {
|
||||||
if (!sdrSquelchSupported) return;
|
if (!sdrSquelchSupported) return;
|
||||||
|
sdrSquelchLocalAt = Date.now();
|
||||||
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
||||||
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
|
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
|
||||||
postPath(
|
postPath(
|
||||||
@@ -6419,6 +6584,8 @@ function setSdrSquelch(thresholdDb, enabled, options = {}) {
|
|||||||
renderSdrSquelch();
|
renderSdrSquelch();
|
||||||
if (options.submit !== false) submitSdrSquelch();
|
if (options.submit !== false) submitSdrSquelch();
|
||||||
}
|
}
|
||||||
|
var SDR_SQUELCH_HOLD_MS = 2e3;
|
||||||
|
var sdrSquelchLocalAt = 0;
|
||||||
var SQUELCH_NOISE_WINDOW_MS = 1e4;
|
var SQUELCH_NOISE_WINDOW_MS = 1e4;
|
||||||
var SQUELCH_NOISE_MARGIN_DB = 5;
|
var SQUELCH_NOISE_MARGIN_DB = 5;
|
||||||
var SQUELCH_MEASURE_MS = 1500;
|
var SQUELCH_MEASURE_MS = 1500;
|
||||||
@@ -6451,6 +6618,7 @@ function updateSdrSquelchControlVisibility() {
|
|||||||
function syncSdrSquelchFromServer(enabled, thresholdDb) {
|
function syncSdrSquelchFromServer(enabled, thresholdDb) {
|
||||||
if (squelchDragPointerId !== null) return;
|
if (squelchDragPointerId !== null) return;
|
||||||
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
|
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
|
||||||
|
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
|
||||||
sdrSquelchEnabled = enabled;
|
sdrSquelchEnabled = enabled;
|
||||||
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
|
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
|
||||||
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
||||||
@@ -7434,9 +7602,17 @@ function updateDecodeStatus(text) {
|
|||||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
||||||
|
"lrpt_image",
|
||||||
|
"lrpt_progress",
|
||||||
|
"wefax",
|
||||||
|
"wefax_progress",
|
||||||
|
"sstv",
|
||||||
|
"sstv_progress"
|
||||||
|
]);
|
||||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
@@ -7482,7 +7658,7 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
|
|||||||
}
|
}
|
||||||
function restoreDecodeHistoryGroup(kind, messages) {
|
function restoreDecodeHistoryGroup(kind, messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||||
}
|
}
|
||||||
@@ -7502,16 +7678,15 @@ function connectDecode() {
|
|||||||
let historySettled = false;
|
let historySettled = false;
|
||||||
let historyWorkerDone = false;
|
let historyWorkerDone = false;
|
||||||
let historyFallbackStarted = false;
|
let historyFallbackStarted = false;
|
||||||
|
let historyRetried = false;
|
||||||
let historyBatchDrainScheduled = false;
|
let historyBatchDrainScheduled = false;
|
||||||
let historyTotal = 0;
|
let historyTotal = 0;
|
||||||
let historyProcessed = 0;
|
let historyProcessed = 0;
|
||||||
const historyGroupQueue = [];
|
const historyGroupQueue = [];
|
||||||
const liveBuffer = [];
|
const liveBuffer = [];
|
||||||
function flushLiveBuffer() {
|
function releaseLiveBuffer() {
|
||||||
|
if (historySettled) return;
|
||||||
historySettled = true;
|
historySettled = true;
|
||||||
terminateDecodeHistoryWorker();
|
|
||||||
setDecodeHistoryReplayActive(false);
|
|
||||||
setDecodeHistoryOverlayVisible(false);
|
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try {
|
try {
|
||||||
dispatchDecodeMessage(msg);
|
dispatchDecodeMessage(msg);
|
||||||
@@ -7520,19 +7695,23 @@ function connectDecode() {
|
|||||||
}
|
}
|
||||||
liveBuffer.length = 0;
|
liveBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
function finishHistoryReplay() {
|
||||||
|
clearTimeout(historyTimeout);
|
||||||
|
releaseLiveBuffer();
|
||||||
|
terminateDecodeHistoryWorker();
|
||||||
|
setDecodeHistoryReplayActive(false);
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
|
}
|
||||||
function updateHistoryReplayOverlay() {
|
function updateHistoryReplayOverlay() {
|
||||||
setDecodeHistoryOverlayVisible(
|
setDecodeHistoryOverlayVisible(
|
||||||
true,
|
true,
|
||||||
"Loading decode history…",
|
"Loading decode history…",
|
||||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||||
|
historyTotal > 0 ? historyProcessed / historyTotal : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function maybeFinishHistoryReplay() {
|
function maybeFinishHistoryReplay() {
|
||||||
if (historySettled) return;
|
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
flushLiveBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function pumpDecodeHistoryGroupQueue() {
|
function pumpDecodeHistoryGroupQueue() {
|
||||||
historyBatchDrainScheduled = false;
|
historyBatchDrainScheduled = false;
|
||||||
@@ -7586,17 +7765,25 @@ function connectDecode() {
|
|||||||
if (historyFallbackStarted || historySettled) return;
|
if (historyFallbackStarted || historySettled) return;
|
||||||
historyFallbackStarted = true;
|
historyFallbackStarted = true;
|
||||||
loadDecodeHistoryOnMainThread((groups) => {
|
loadDecodeHistoryOnMainThread((groups) => {
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
const total = totalDecodeHistoryMessages(groups);
|
const total = totalDecodeHistoryMessages(groups);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
enqueueDecodeHistoryGroups(groups);
|
enqueueDecodeHistoryGroups(groups);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
finishHistoryReplay();
|
||||||
}
|
}
|
||||||
}, (err) => {
|
}, (err) => {
|
||||||
console.error("Decode history fallback failed", err);
|
console.error("Decode history fallback failed", err);
|
||||||
clearTimeout(historyTimeout);
|
if (historyRetried) {
|
||||||
flushLiveBuffer();
|
showHint("Decode history unavailable", 3e3);
|
||||||
|
finishHistoryReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyRetried = true;
|
||||||
|
historyFallbackStarted = false;
|
||||||
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||||
|
setTimeout(() => {
|
||||||
|
startDecodeHistoryFallback();
|
||||||
|
}, 2e3);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function startDecodeHistoryWorkerReplay() {
|
function startDecodeHistoryWorkerReplay() {
|
||||||
@@ -7657,10 +7844,9 @@ function connectDecode() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const historyTimeout = setTimeout(() => {
|
const historyTimeout = setTimeout(() => {
|
||||||
if (!historySettled) {
|
if (historySettled) return;
|
||||||
terminateDecodeHistoryWorker();
|
releaseLiveBuffer();
|
||||||
flushLiveBuffer();
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||||
}
|
|
||||||
}, 2e4);
|
}, 2e4);
|
||||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
decodeSource = new EventSource("/decode");
|
decodeSource = new EventSource("/decode");
|
||||||
@@ -7682,7 +7868,7 @@ function connectDecode() {
|
|||||||
const wasClosed = source.readyState === 2;
|
const wasClosed = source.readyState === 2;
|
||||||
source.close();
|
source.close();
|
||||||
terminateDecodeHistoryWorker();
|
terminateDecodeHistoryWorker();
|
||||||
if (!historySettled) flushLiveBuffer();
|
if (!historySettled) releaseLiveBuffer();
|
||||||
if (wasClosed) {
|
if (wasClosed) {
|
||||||
updateDecodeStatus("Decode not available (check client audio config)");
|
updateDecodeStatus("Decode not available (check client audio config)");
|
||||||
setTimeout(connectDecode, 1e4);
|
setTimeout(connectDecode, 1e4);
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow
|
||||||
renderLocalAprsSymbol
|
} from "./chunk-OPEIVJGD.js";
|
||||||
} from "./chunk-ROTCLFXS.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -114,51 +111,27 @@ function updateAprsChipState() {
|
|||||||
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
}
|
}
|
||||||
function renderAprsRow(pkt, isFresh) {
|
async function copyAprsCoords(text) {
|
||||||
const row = document.createElement("div");
|
try {
|
||||||
row.className = "aprs-packet";
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!clipboard) return;
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
await clipboard.writeText(text);
|
||||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
showAprsHint("Coordinates copied", 1200);
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
} catch {
|
||||||
const category = aprsPacketCategory(pkt);
|
showAprsHint("Copy failed", 1500);
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
|
||||||
const distance = aprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
aprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
|
||||||
try {
|
|
||||||
const clipboard = Reflect.get(navigator, "clipboard");
|
|
||||||
if (clipboard) {
|
|
||||||
await clipboard.writeText(raw);
|
|
||||||
showAprsHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showAprsHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return row;
|
}
|
||||||
|
function renderAprsRow(pkt, isFresh) {
|
||||||
|
return renderAprsPacketRow(pkt, {
|
||||||
|
fresh: isFresh,
|
||||||
|
distance: aprsDistanceText(pkt),
|
||||||
|
onMap: (lat, lon) => {
|
||||||
|
aprsWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
},
|
||||||
|
onCopy: (text) => {
|
||||||
|
void copyAprsCoords(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function renderAprsHistory() {
|
function renderAprsHistory() {
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
@@ -223,15 +196,17 @@ function pruneAprsHistoryView() {
|
|||||||
updateAprsBar();
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
}
|
}
|
||||||
|
function plotAprsPacket(pkt) {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
|
||||||
|
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
function addAprsPacket(pkt) {
|
function addAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
aprsPacketHistory.unshift(pkt);
|
aprsPacketHistory.unshift(pkt);
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(pkt);
|
||||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
|
||||||
}
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
scheduleAprsHistoryRender();
|
scheduleAprsHistoryRender();
|
||||||
}
|
}
|
||||||
@@ -248,9 +223,7 @@ function onServerAprsBatch(packets) {
|
|||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(next);
|
||||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
|
||||||
}
|
|
||||||
if (next.crcOk) hasCrcOk = true;
|
if (next.crcOk) hasCrcOk = true;
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -314,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerAprsBatch,
|
onBatch: onServerAprsBatch,
|
||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView
|
prune: pruneAprsHistoryView,
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// src/plugins/aprs-shared.ts
|
||||||
|
function escapeAprsHtml(value) {
|
||||||
|
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function aprsPacketCategory(packet) {
|
||||||
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
|
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function aprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position":
|
||||||
|
return "Position";
|
||||||
|
case "message":
|
||||||
|
return "Message";
|
||||||
|
case "weather":
|
||||||
|
return "Weather";
|
||||||
|
case "telemetry":
|
||||||
|
return "Telemetry";
|
||||||
|
default:
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aprsAgeText(timestampMs) {
|
||||||
|
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||||
|
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
return `${String(Math.round(minutes / 60))}h ago`;
|
||||||
|
}
|
||||||
|
function aprsPacketSignature(packet) {
|
||||||
|
return [
|
||||||
|
packet.srcCall ?? "",
|
||||||
|
packet.destCall ?? "",
|
||||||
|
packet.path ?? "",
|
||||||
|
packet.info ?? "",
|
||||||
|
packet.type ?? "",
|
||||||
|
packet.lat?.toFixed(4) ?? "",
|
||||||
|
packet.lon?.toFixed(4) ?? ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
return packets.filter((packet) => {
|
||||||
|
const signature = aprsPacketSignature(packet);
|
||||||
|
if (seen.has(signature)) return false;
|
||||||
|
seen.add(signature);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!bytes?.length) return "--";
|
||||||
|
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function renderAprsInfo(packet) {
|
||||||
|
if (packet.info_bytes?.length) {
|
||||||
|
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||||
|
}
|
||||||
|
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||||
|
}
|
||||||
|
function renderAprsByte(byte) {
|
||||||
|
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function renderAprsCharacter(character) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function escapeAprsCharacter(character) {
|
||||||
|
if (character === "<") return "<";
|
||||||
|
if (character === ">") return ">";
|
||||||
|
if (character === "&") return "&";
|
||||||
|
if (character === '"') return """;
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
var APRS_SPRITE_COLUMNS = 16;
|
||||||
|
var APRS_SPRITE_CELL_PX = 24;
|
||||||
|
var APRS_SPRITE_FIRST_CODE = 33;
|
||||||
|
var APRS_SPRITE_LAST_CODE = 126;
|
||||||
|
function aprsSpriteOffset(code) {
|
||||||
|
if (code.length !== 1) return null;
|
||||||
|
const point = code.charCodeAt(0);
|
||||||
|
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
|
||||||
|
const index = point - APRS_SPRITE_FIRST_CODE;
|
||||||
|
const column = index % APRS_SPRITE_COLUMNS;
|
||||||
|
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
|
||||||
|
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
|
||||||
|
}
|
||||||
|
function aprsSymbolSprite(symbolTable, symbolCode) {
|
||||||
|
if (!symbolTable || !symbolCode) return null;
|
||||||
|
const symbolOffset = aprsSpriteOffset(symbolCode);
|
||||||
|
if (!symbolOffset) return null;
|
||||||
|
if (symbolTable === "/") {
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-primary",
|
||||||
|
backgroundPosition: symbolOffset,
|
||||||
|
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (symbolTable === "\\") {
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-alternate",
|
||||||
|
backgroundPosition: symbolOffset,
|
||||||
|
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const overlayOffset = aprsSpriteOffset(symbolTable);
|
||||||
|
if (!overlayOffset) return null;
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-overlaid",
|
||||||
|
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
|
||||||
|
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function renderAprsSymbolSlot(packet, escapeHtml) {
|
||||||
|
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
|
||||||
|
}
|
||||||
|
function renderLocalAprsSymbol(packet, escapeHtml) {
|
||||||
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
|
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
||||||
|
if (sprite) {
|
||||||
|
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
|
||||||
|
}
|
||||||
|
const symbol = escapeHtml(packet.symbolCode);
|
||||||
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
|
}
|
||||||
|
function normalizeAprsPacket(packet, receiver) {
|
||||||
|
return {
|
||||||
|
rig_id: packet.rig_id || null,
|
||||||
|
receiver,
|
||||||
|
srcCall: packet.src_call ?? "",
|
||||||
|
destCall: packet.dest_call ?? "",
|
||||||
|
path: packet.path ?? "",
|
||||||
|
info: packet.info ?? "",
|
||||||
|
info_bytes: packet.info_bytes ?? [],
|
||||||
|
type: packet.packet_type ?? "",
|
||||||
|
crcOk: packet.crc_ok ?? false,
|
||||||
|
ts_ms: packet.ts_ms ?? null,
|
||||||
|
lat: packet.lat ?? null,
|
||||||
|
lon: packet.lon ?? null,
|
||||||
|
symbolTable: packet.symbol_table ?? null,
|
||||||
|
symbolCode: packet.symbol_code ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function fahrenheitToCelsius(fahrenheit) {
|
||||||
|
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
|
||||||
|
}
|
||||||
|
function summarizeAprsWeather(info) {
|
||||||
|
const parts = [];
|
||||||
|
const temperature = /t(-?\d{2,3})/.exec(info);
|
||||||
|
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
|
||||||
|
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
|
||||||
|
if (wind) {
|
||||||
|
const gust = /g(\d{3})/.exec(info);
|
||||||
|
const knots = Number(wind[2]);
|
||||||
|
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
|
||||||
|
}
|
||||||
|
const humidity = /h(\d{2})/.exec(info);
|
||||||
|
if (humidity) {
|
||||||
|
const value = Number(humidity[1]);
|
||||||
|
parts.push(`${value === 0 ? 100 : value}% RH`);
|
||||||
|
}
|
||||||
|
const pressure = /b(\d{5})/.exec(info);
|
||||||
|
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
|
||||||
|
const rain = /r(\d{3})/.exec(info);
|
||||||
|
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
function summarizeAprsTelemetry(info) {
|
||||||
|
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
|
||||||
|
if (!match?.[2]) return null;
|
||||||
|
const channels = match[2].split(",").filter((value) => value.length > 0);
|
||||||
|
const bits = match[3] ? ` · bits ${match[3]}` : "";
|
||||||
|
return `#${match[1]} · ${channels.join(" ")}${bits}`;
|
||||||
|
}
|
||||||
|
function summarizeAprsMessage(info) {
|
||||||
|
const match = /^:([^:]{9}):(.*)$/.exec(info);
|
||||||
|
if (!match?.[1] || match[2] == null) return null;
|
||||||
|
const addressee = match[1].trim();
|
||||||
|
const text = match[2].replace(/\{\d+\s*$/, "").trim();
|
||||||
|
return `→ ${addressee}: ${text}`;
|
||||||
|
}
|
||||||
|
function summarizeAprsCourseSpeed(info) {
|
||||||
|
const match = /(\d{3})\/(\d{3})/.exec(info);
|
||||||
|
if (!match) return null;
|
||||||
|
const knots = Number(match[2]);
|
||||||
|
if (knots === 0) return null;
|
||||||
|
return `${Number(match[1])}° ${knots} kt`;
|
||||||
|
}
|
||||||
|
function summarizeAprsPayload(packet) {
|
||||||
|
const info = packet.info ?? "";
|
||||||
|
if (!info) return null;
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
if (category === "message") return summarizeAprsMessage(info);
|
||||||
|
if (category === "weather") return summarizeAprsWeather(info);
|
||||||
|
if (category === "telemetry") return summarizeAprsTelemetry(info);
|
||||||
|
if (category === "position") {
|
||||||
|
const parts = [];
|
||||||
|
if (packet.lat != null && packet.lon != null) {
|
||||||
|
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
const courseSpeed = summarizeAprsCourseSpeed(info);
|
||||||
|
if (courseSpeed) parts.push(courseSpeed);
|
||||||
|
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
|
||||||
|
if (comment && comment.length <= 60) parts.push(comment);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
const text = info.replace(/^[>;<?]/, "").trim();
|
||||||
|
return text.length ? text : null;
|
||||||
|
}
|
||||||
|
function renderAprsPacketRow(packet, options = {}) {
|
||||||
|
const row = document.createElement("details");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (options.fresh) row.classList.add("aprs-packet-new");
|
||||||
|
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
const summary = summarizeAprsPayload(packet);
|
||||||
|
const hasPosition = packet.lat != null && packet.lon != null;
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||||
|
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyButton = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
aprsPacketCategory,
|
||||||
|
aprsAgeText,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
aprsSymbolSprite,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsPacketRow
|
||||||
|
};
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
// src/plugins/aprs-shared.ts
|
|
||||||
function aprsPacketCategory(packet) {
|
|
||||||
const type = (packet.type ?? "").toLowerCase();
|
|
||||||
const info = (packet.info ?? "").toLowerCase();
|
|
||||||
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
|
||||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
|
||||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
|
||||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
function aprsCategoryLabel(category) {
|
|
||||||
switch (category) {
|
|
||||||
case "position":
|
|
||||||
return "Position";
|
|
||||||
case "message":
|
|
||||||
return "Message";
|
|
||||||
case "weather":
|
|
||||||
return "Weather";
|
|
||||||
case "telemetry":
|
|
||||||
return "Telemetry";
|
|
||||||
default:
|
|
||||||
return "Other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function aprsAgeText(timestampMs) {
|
|
||||||
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
|
||||||
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${String(seconds)}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${String(minutes)}m ago`;
|
|
||||||
return `${String(Math.round(minutes / 60))}h ago`;
|
|
||||||
}
|
|
||||||
function aprsPacketSignature(packet) {
|
|
||||||
return [
|
|
||||||
packet.srcCall ?? "",
|
|
||||||
packet.destCall ?? "",
|
|
||||||
packet.path ?? "",
|
|
||||||
packet.info ?? "",
|
|
||||||
packet.type ?? "",
|
|
||||||
packet.lat?.toFixed(4) ?? "",
|
|
||||||
packet.lon?.toFixed(4) ?? ""
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
function collapseAprsDuplicates(packets) {
|
|
||||||
const seen = /* @__PURE__ */ new Set();
|
|
||||||
return packets.filter((packet) => {
|
|
||||||
const signature = aprsPacketSignature(packet);
|
|
||||||
if (seen.has(signature)) return false;
|
|
||||||
seen.add(signature);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function aprsHexBytes(bytes) {
|
|
||||||
if (!bytes?.length) return "--";
|
|
||||||
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
function renderAprsInfo(packet) {
|
|
||||||
if (packet.info_bytes?.length) {
|
|
||||||
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
|
||||||
}
|
|
||||||
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
|
||||||
}
|
|
||||||
function renderAprsByte(byte) {
|
|
||||||
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function renderAprsCharacter(character) {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function escapeAprsCharacter(character) {
|
|
||||||
if (character === "<") return "<";
|
|
||||||
if (character === ">") return ">";
|
|
||||||
if (character === "&") return "&";
|
|
||||||
if (character === '"') return """;
|
|
||||||
return character;
|
|
||||||
}
|
|
||||||
var APRS_SPRITE_COLUMNS = 16;
|
|
||||||
var APRS_SPRITE_CELL_PX = 24;
|
|
||||||
var APRS_SPRITE_FIRST_CODE = 33;
|
|
||||||
var APRS_SPRITE_LAST_CODE = 126;
|
|
||||||
function aprsSpriteOffset(code) {
|
|
||||||
if (code.length !== 1) return null;
|
|
||||||
const point = code.charCodeAt(0);
|
|
||||||
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
|
|
||||||
const index = point - APRS_SPRITE_FIRST_CODE;
|
|
||||||
const column = index % APRS_SPRITE_COLUMNS;
|
|
||||||
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
|
|
||||||
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
|
|
||||||
}
|
|
||||||
function aprsSymbolSprite(symbolTable, symbolCode) {
|
|
||||||
if (!symbolTable || !symbolCode) return null;
|
|
||||||
const symbolOffset = aprsSpriteOffset(symbolCode);
|
|
||||||
if (!symbolOffset) return null;
|
|
||||||
if (symbolTable === "/") {
|
|
||||||
return {
|
|
||||||
className: "aprs-symbol-primary",
|
|
||||||
backgroundPosition: symbolOffset,
|
|
||||||
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (symbolTable === "\\") {
|
|
||||||
return {
|
|
||||||
className: "aprs-symbol-alternate",
|
|
||||||
backgroundPosition: symbolOffset,
|
|
||||||
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const overlayOffset = aprsSpriteOffset(symbolTable);
|
|
||||||
if (!overlayOffset) return null;
|
|
||||||
return {
|
|
||||||
className: "aprs-symbol-overlaid",
|
|
||||||
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
|
|
||||||
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function renderLocalAprsSymbol(packet, escapeHtml) {
|
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
|
||||||
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
|
||||||
if (sprite) {
|
|
||||||
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
|
|
||||||
}
|
|
||||||
const symbol = escapeHtml(packet.symbolCode);
|
|
||||||
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
|
||||||
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
|
||||||
}
|
|
||||||
function normalizeAprsPacket(packet, receiver) {
|
|
||||||
return {
|
|
||||||
rig_id: packet.rig_id || null,
|
|
||||||
receiver,
|
|
||||||
srcCall: packet.src_call ?? "",
|
|
||||||
destCall: packet.dest_call ?? "",
|
|
||||||
path: packet.path ?? "",
|
|
||||||
info: packet.info ?? "",
|
|
||||||
info_bytes: packet.info_bytes ?? [],
|
|
||||||
type: packet.packet_type ?? "",
|
|
||||||
crcOk: packet.crc_ok ?? false,
|
|
||||||
ts_ms: packet.ts_ms ?? null,
|
|
||||||
lat: packet.lat ?? null,
|
|
||||||
lon: packet.lon ?? null,
|
|
||||||
symbolTable: packet.symbol_table ?? null,
|
|
||||||
symbolCode: packet.symbol_code ?? null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
aprsPacketCategory,
|
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsAgeText,
|
|
||||||
collapseAprsDuplicates,
|
|
||||||
aprsHexBytes,
|
|
||||||
renderAprsInfo,
|
|
||||||
aprsSymbolSprite,
|
|
||||||
renderLocalAprsSymbol,
|
|
||||||
normalizeAprsPacket
|
|
||||||
};
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
// src/decode-history-worker.ts
|
// src/decode-history-worker.ts
|
||||||
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||||
var workerScope = self;
|
var workerScope = self;
|
||||||
function decodeCborUint(view, bytes, state, additional) {
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
const offset = state.offset;
|
const offset = state.offset;
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow
|
||||||
renderLocalAprsSymbol
|
} from "./chunk-OPEIVJGD.js";
|
||||||
} from "./chunk-ROTCLFXS.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -15,7 +12,6 @@ import {
|
|||||||
|
|
||||||
// src/plugins/hf-aprs.ts
|
// src/plugins/hf-aprs.ts
|
||||||
var hfAprsWindow = window;
|
var hfAprsWindow = window;
|
||||||
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
|
||||||
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
@@ -102,51 +98,27 @@ function updateHfAprsChipState() {
|
|||||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
}
|
}
|
||||||
function renderHfAprsRow(pkt, isFresh) {
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
const row = document.createElement("div");
|
return renderAprsPacketRow(pkt, {
|
||||||
row.className = "aprs-packet";
|
fresh: isFresh,
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
badge: "HF",
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
distance: hfAprsDistanceText(pkt),
|
||||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
onMap: (lat, lon) => {
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
hfAprsWindow.navigateToAprsMap?.(lat, lon);
|
||||||
const category = aprsPacketCategory(pkt);
|
},
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
onCopy: (text) => {
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
void copyHfAprsCoords(text);
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
|
}
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
|
||||||
const distance = hfAprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
hfAprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
}
|
||||||
if (copyBtn) {
|
async function copyHfAprsCoords(text) {
|
||||||
copyBtn.addEventListener("click", () => {
|
try {
|
||||||
void (async () => {
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
if (!clipboard) return;
|
||||||
try {
|
await clipboard.writeText(text);
|
||||||
const clipboard = Reflect.get(navigator, "clipboard");
|
hostCore.showHint("Coordinates copied", 1200);
|
||||||
if (clipboard) {
|
} catch {
|
||||||
await clipboard.writeText(raw);
|
hostCore.showHint("Copy failed", 1500);
|
||||||
hostCore.showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hostCore.showHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
function renderHfAprsHistory() {
|
function renderHfAprsHistory() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
aprsSymbolSprite
|
aprsSymbolSprite
|
||||||
} from "./chunk-ROTCLFXS.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
|
||||||
// src/map-core.ts
|
// src/map-core.ts
|
||||||
function mapEl(id) {
|
function mapEl(id) {
|
||||||
@@ -62,6 +62,7 @@ var mapWindow = window;
|
|||||||
const mapMarkers = /* @__PURE__ */ new Set();
|
const mapMarkers = /* @__PURE__ */ new Set();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||||
let mapSearchFilter = "";
|
let mapSearchFilter = "";
|
||||||
let mapRigFilter = "";
|
let mapRigFilter = "";
|
||||||
@@ -838,38 +839,36 @@ var mapWindow = window;
|
|||||||
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let helperText = "";
|
const noun = kind === "band" ? "bands" : "sources";
|
||||||
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
|
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
|
||||||
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
|
const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
|
||||||
if (kind === "source") {
|
const allChip = document.createElement("button");
|
||||||
if (noneSelected) {
|
allChip.type = "button";
|
||||||
helperText = "All sources visible — click to filter";
|
allChip.className = "map-locator-chip map-locator-chip-all";
|
||||||
}
|
if (showingAll) allChip.classList.add("is-active");
|
||||||
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
|
allChip.dataset.filterKind = kind;
|
||||||
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
|
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
|
||||||
}
|
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
|
||||||
|
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
|
||||||
|
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
|
||||||
|
container.appendChild(allChip);
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "map-locator-chip";
|
btn.className = "map-locator-chip";
|
||||||
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
|
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
|
||||||
if (kind === "source" && noneSelected) {
|
if (showingAll) {
|
||||||
btn.classList.add("is-default");
|
btn.classList.add("is-default");
|
||||||
} else if (!isActive) {
|
} else if (!isActive) {
|
||||||
btn.classList.add("is-inactive");
|
btn.classList.add("is-inactive");
|
||||||
}
|
}
|
||||||
|
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
|
||||||
btn.dataset.filterKind = kind;
|
btn.dataset.filterKind = kind;
|
||||||
btn.dataset.filterKey = item.key;
|
btn.dataset.filterKey = item.key;
|
||||||
btn.style.setProperty("--chip-color", item.color);
|
btn.style.setProperty("--chip-color", item.color);
|
||||||
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
if (helperText) {
|
|
||||||
const hint = document.createElement("span");
|
|
||||||
hint.className = "map-locator-empty";
|
|
||||||
hint.textContent = helperText;
|
|
||||||
container.appendChild(hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function renderMapLocatorPhaseRow(container, phase) {
|
function renderMapLocatorPhaseRow(container, phase) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -977,11 +976,10 @@ var mapWindow = window;
|
|||||||
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
|
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
|
||||||
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
||||||
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
||||||
|
choiceLabelEl.textContent = "Show";
|
||||||
if (mapLocatorFilter.phase === "band") {
|
if (mapLocatorFilter.phase === "band") {
|
||||||
choiceLabelEl.textContent = "Visible Bands";
|
|
||||||
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
||||||
} else {
|
} else {
|
||||||
choiceLabelEl.textContent = "Visible Sources";
|
|
||||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||||
}
|
}
|
||||||
syncLocatorMarkerStyles();
|
syncLocatorMarkerStyles();
|
||||||
@@ -1335,7 +1333,8 @@ var mapWindow = window;
|
|||||||
function applyMapOverlayPanelVisibility() {
|
function applyMapOverlayPanelVisibility() {
|
||||||
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
|
||||||
|
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
||||||
}
|
}
|
||||||
function updateMapOverlayToggleButton() {
|
function updateMapOverlayToggleButton() {
|
||||||
const btn = mapEl("map-overlay-toggle-btn");
|
const btn = mapEl("map-overlay-toggle-btn");
|
||||||
@@ -1532,7 +1531,13 @@ var mapWindow = window;
|
|||||||
const kind = String(chip.dataset.filterKind || "");
|
const kind = String(chip.dataset.filterKind || "");
|
||||||
const key = String(chip.dataset.filterKey || "");
|
const key = String(chip.dataset.filterKey || "");
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
if (key === MAP_FILTER_ALL_KEY) {
|
||||||
|
if (kind === "source") {
|
||||||
|
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER)) mapFilter[srcKey] = false;
|
||||||
|
} else {
|
||||||
|
mapLocatorFilter.bands.clear();
|
||||||
|
}
|
||||||
|
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
||||||
const sourceKey = key;
|
const sourceKey = key;
|
||||||
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
||||||
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
|
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
|
||||||
@@ -2147,14 +2152,16 @@ var mapWindow = window;
|
|||||||
function updateMapContactPathsToggle() {
|
function updateMapContactPathsToggle() {
|
||||||
const btn = mapEl("map-contact-paths-toggle");
|
const btn = mapEl("map-contact-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapDecodeContactPathsEnabled ? "Directed decode paths are drawn when the target locator is known" : "Directed decode paths are hidden";
|
||||||
}
|
}
|
||||||
function updateMapP2pPathsToggle() {
|
function updateMapP2pPathsToggle() {
|
||||||
const btn = mapEl("map-p2p-paths-toggle");
|
const btn = mapEl("map-p2p-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapP2pRadioPathsEnabled ? "TRX paths are drawn from a station popup" : "TRX paths are hidden";
|
||||||
}
|
}
|
||||||
function scheduleDecodeMapMaintenance() {
|
function scheduleDecodeMapMaintenance() {
|
||||||
if (C.decodeHistoryMapRenderingDeferred()) {
|
if (C.decodeHistoryMapRenderingDeferred()) {
|
||||||
@@ -3093,5 +3100,6 @@ var mapWindow = window;
|
|||||||
bandForHz,
|
bandForHz,
|
||||||
reverseGeocodeLocation
|
reverseGeocodeLocation
|
||||||
};
|
};
|
||||||
|
window.trxPluginRuntime.syncMapAll();
|
||||||
autoInitIfVisible();
|
autoInitIfVisible();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/sstv.ts
|
||||||
|
var sstvWindow = window;
|
||||||
|
var sstvDom = {
|
||||||
|
status: document.getElementById("sstv-status"),
|
||||||
|
liveView: document.getElementById("sstv-live-view"),
|
||||||
|
historyView: document.getElementById("sstv-history-view"),
|
||||||
|
liveContainer: document.getElementById("sstv-live-container"),
|
||||||
|
liveInfo: document.getElementById("sstv-live-info"),
|
||||||
|
liveCanvas: document.getElementById("sstv-live-canvas"),
|
||||||
|
liveLatest: document.getElementById("sstv-live-latest"),
|
||||||
|
historyList: document.getElementById("sstv-history-list"),
|
||||||
|
historyCount: document.getElementById("sstv-history-count"),
|
||||||
|
filterInput: document.getElementById("sstv-filter"),
|
||||||
|
sortSelect: document.getElementById("sstv-sort"),
|
||||||
|
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("sstv-view-history")
|
||||||
|
};
|
||||||
|
var SSTV_MAX_IMAGES = 100;
|
||||||
|
var sstvHistory = [];
|
||||||
|
var liveCtx = null;
|
||||||
|
var liveMode = "";
|
||||||
|
var liveHeight = 0;
|
||||||
|
var liveRows = 0;
|
||||||
|
var activeView = "live";
|
||||||
|
var filterText = "";
|
||||||
|
function retentionMs() {
|
||||||
|
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneHistory() {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||||
|
}
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleUi(key, job) {
|
||||||
|
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function imageUrl(image) {
|
||||||
|
if (!image.path) return null;
|
||||||
|
const filename = image.path.split(/[\\/]/).pop();
|
||||||
|
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||||
|
}
|
||||||
|
function decodeBase64(data) {
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
function switchView(view) {
|
||||||
|
activeView = view;
|
||||||
|
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||||
|
button?.classList.remove("sat-view-active");
|
||||||
|
}
|
||||||
|
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||||
|
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderHistoryTable();
|
||||||
|
}
|
||||||
|
sstvDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
|
switchView("live");
|
||||||
|
});
|
||||||
|
sstvDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
|
switchView("history");
|
||||||
|
});
|
||||||
|
function beginPicture(mode, width, height) {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!canvas || width <= 0 || height <= 0) return;
|
||||||
|
liveMode = mode;
|
||||||
|
liveHeight = height;
|
||||||
|
liveRows = 0;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
liveCtx = canvas.getContext("2d");
|
||||||
|
if (!liveCtx) return;
|
||||||
|
liveCtx.fillStyle = "#606060";
|
||||||
|
liveCtx.fillRect(0, 0, width, height);
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||||
|
updateLiveInfo();
|
||||||
|
}
|
||||||
|
function updateLiveInfo() {
|
||||||
|
if (!sstvDom.liveInfo) return;
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||||
|
sstvDom.liveInfo.textContent = liveMode ? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}` : "";
|
||||||
|
}
|
||||||
|
function paintRow(line, rgb) {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!liveCtx || !canvas) return;
|
||||||
|
const width = canvas.width;
|
||||||
|
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||||
|
const row = liveCtx.createImageData(width, 1);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||||
|
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||||
|
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||||
|
row.data[x * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
liveCtx.putImageData(row, 0, line);
|
||||||
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
|
}
|
||||||
|
function onProgress(msg) {
|
||||||
|
if (msg.state) {
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||||
|
const line = msg.line;
|
||||||
|
const rgb = decodeBase64(msg.line_data);
|
||||||
|
scheduleUi(`sstv-row-${line}`, () => {
|
||||||
|
paintRow(line, rgb);
|
||||||
|
updateLiveInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onImage(msg) {
|
||||||
|
const image = { ...msg };
|
||||||
|
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||||
|
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||||
|
sstvHistory.push(image);
|
||||||
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
|
pruneHistory();
|
||||||
|
if (sstvDom.status) {
|
||||||
|
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
|
}
|
||||||
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
|
}
|
||||||
|
function renderLatestCard() {
|
||||||
|
if (!sstvDom.liveLatest) return;
|
||||||
|
const latest = sstvHistory[sstvHistory.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = imageUrl(latest);
|
||||||
|
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||||
|
const state = latest.complete ? "complete" : "partial";
|
||||||
|
sstvDom.liveLatest.innerHTML = `
|
||||||
|
<div class="sat-latest-card">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||||
|
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||||
|
</div>
|
||||||
|
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||||
|
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||||
|
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||||
|
</a>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function filteredHistory() {
|
||||||
|
const text = filterText.trim().toLowerCase();
|
||||||
|
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
||||||
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
function renderHistoryTable() {
|
||||||
|
if (!sstvDom.historyList) return;
|
||||||
|
pruneHistory();
|
||||||
|
const rows = filteredHistory();
|
||||||
|
sstvDom.historyList.innerHTML = rows.map((image) => {
|
||||||
|
const url = imageUrl(image);
|
||||||
|
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||||
|
const lines = image.complete ? String(image.lines ?? 0) : `${image.lines ?? 0} (partial)`;
|
||||||
|
return `<div class="sat-history-row">
|
||||||
|
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||||
|
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||||
|
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||||
|
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||||
|
<span class="sat-col-link">${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>` : "--"}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
if (sstvDom.historyCount) {
|
||||||
|
sstvDom.historyCount.textContent = rows.length ? `${rows.length} picture${rows.length === 1 ? "" : "s"}` : "No pictures yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sstvDom.filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = sstvDom.filterInput?.value ?? "";
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
sstvDom.sortSelect?.addEventListener("change", () => {
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
function restoreHistory(entries) {
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
for (const entry of entries) onImage(entry);
|
||||||
|
}
|
||||||
|
function resetHistoryView() {
|
||||||
|
sstvHistory = [];
|
||||||
|
liveRows = 0;
|
||||||
|
liveMode = "";
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled) {
|
||||||
|
const button = sstvDom.toggleBtn;
|
||||||
|
if (!button) return;
|
||||||
|
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||||
|
button.setAttribute("aria-pressed", String(enabled));
|
||||||
|
button.classList.toggle("is-active", enabled);
|
||||||
|
};
|
||||||
|
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (sstvDom.toggleBtn) {
|
||||||
|
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_sstv_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_sstv_decode");
|
||||||
|
resetHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
renderLatestCard();
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv",
|
||||||
|
onMessage: onImage,
|
||||||
|
restore: restoreHistory,
|
||||||
|
prune: renderHistoryTable,
|
||||||
|
reset: resetHistoryView
|
||||||
|
});
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv_progress",
|
||||||
|
onMessage: onProgress
|
||||||
|
});
|
||||||
@@ -220,9 +220,7 @@ function onServerVdesBatch(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -248,13 +246,15 @@ if (vdesFilterInput) {
|
|||||||
renderVdesHistory();
|
renderVdesHistory();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function plotVdesMessage(msg) {
|
||||||
|
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
||||||
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
|
}
|
||||||
function onServerVdes(msg) {
|
function onServerVdes(msg) {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function pruneVdesHistoryView() {
|
function pruneVdesHistoryView() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
@@ -268,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerVdesBatch,
|
onBatch: onServerVdesBatch,
|
||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView
|
prune: pruneVdesHistoryView,
|
||||||
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -52,14 +53,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||||
<span class="tab-label">Bookmarks</span>
|
<span class="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="digital-modes">
|
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||||
<span class="tab-label">Digital modes</span>
|
<span class="tab-label">Digital modes</span>
|
||||||
|
<!-- A fifth of a phone screen does not hold "Digital modes"; the
|
||||||
|
button keeps the full name for assistive tech. -->
|
||||||
|
<span class="tab-label-short" aria-hidden="true">Digital</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="map">
|
<button class="tab" data-tab="map">
|
||||||
<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>
|
||||||
@@ -99,6 +107,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<option value="phosphor">Phosphor</option>
|
<option value="phosphor">Phosphor</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="header-share-btn" class="header-bar-btn header-share-btn" type="button" aria-label="Copy a link to this frequency" title="Copy a link to this frequency">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5a2.6 2.6 0 0 0 3.9.3l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.5 6.5a2.6 2.6 0 0 0-3.9-.3L3.4 8.4a2.6 2.6 0 0 0 3.7 3.7l1-1"/></svg>
|
||||||
|
</button>
|
||||||
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
||||||
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,13 +171,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="spectrum-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
|
<div id="spectrum-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
|
||||||
<div id="spectrum-controls">
|
<div id="spectrum-controls">
|
||||||
<div id="spectrum-bw-row">
|
<div id="spectrum-bw-row">
|
||||||
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
|
<label id="spectrum-bw-label" class="spectrum-field"><span class="spectrum-field-name">Bandwidth</span><input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /><span class="spectrum-field-unit">kHz</span></label>
|
||||||
<button id="spectrum-bw-set-btn" type="button">Set</button>
|
<button id="spectrum-bw-set-btn" class="spectrum-btn" type="button">Set</button>
|
||||||
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
|
||||||
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
|
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
|
||||||
<div id="spectrum-level-row">
|
<div id="spectrum-level-row">
|
||||||
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
<label class="overview-control spectrum-field" id="spectrum-peak-hold-label"><span class="spectrum-field-name">Peak Hold</span>
|
||||||
<select id="overview-peak-hold" class="status-input">
|
<select id="overview-peak-hold" class="status-input">
|
||||||
<option value="0">Off</option>
|
<option value="0">Off</option>
|
||||||
<option value="500">0.5 s</option>
|
<option value="500">0.5 s</option>
|
||||||
@@ -179,10 +191,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<option value="60000">60 s</option>
|
<option value="60000">60 s</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
|
<label id="spectrum-floor-label" class="spectrum-field"><span class="spectrum-field-name">Floor</span><input type="number" id="spectrum-floor-input" value="-115" step="5" /><span class="spectrum-field-unit">dB</span></label>
|
||||||
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
|
<label id="spectrum-range-label" class="spectrum-field"><span class="spectrum-field-name">Range</span><input type="number" id="spectrum-range-input" value="90" step="10" min="10" /><span class="spectrum-field-unit">dB</span></label>
|
||||||
<button id="spectrum-auto-btn" type="button">Auto</button>
|
<button id="spectrum-auto-btn" class="spectrum-btn" type="button">Auto</button>
|
||||||
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
|
<label id="spectrum-gamma-label" class="spectrum-field"><span class="spectrum-field-name">Contrast</span><input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value" class="spectrum-field-value">1.0</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset</div>
|
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset</div>
|
||||||
@@ -209,11 +221,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="label"><span>Signal strength</span></div>
|
<div class="label"><span>Signal strength</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col">
|
<div class="freq-field frequency-col">
|
||||||
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
|
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
|
||||||
<div class="label" id="freq-label"><span>Frequency</span></div>
|
<div class="label" id="freq-label"><span>Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
||||||
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
|
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
|
||||||
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,6 +593,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||||
<button class="sub-tab" data-subtab="sat">SAT</button>
|
<button class="sub-tab" data-subtab="sat">SAT</button>
|
||||||
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
||||||
|
<button class="sub-tab" data-subtab="sstv">SSTV</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-overview" class="sub-tab-panel">
|
<div id="subtab-overview" class="sub-tab-panel">
|
||||||
<div class="plugin-item" data-decoder="ais">
|
<div class="plugin-item" data-decoder="ais">
|
||||||
@@ -643,6 +656,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="plugin-item" data-decoder="sstv">
|
||||||
|
<strong>SSTV Decoder</strong>
|
||||||
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
|
Slow-Scan Television — pictures in Martin, Scottie, Robot, PD and Wraase modes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="plugin-item" data-decoder="wefax">
|
<div class="plugin-item" data-decoder="wefax">
|
||||||
<strong>WEFAX Decoder</strong>
|
<strong>WEFAX Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
@@ -650,6 +669,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtab-sstv" class="sub-tab-panel" style="display:none;">
|
||||||
|
<div class="ft8-controls">
|
||||||
|
<button id="sstv-decode-toggle-btn" type="button">Enable SSTV</button>
|
||||||
|
<small id="sstv-status" style="color:var(--text-muted);">Idle</small>
|
||||||
|
</div>
|
||||||
|
<!-- View selector -->
|
||||||
|
<div class="sat-view-bar">
|
||||||
|
<button id="sstv-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||||
|
<button id="sstv-view-history" class="sat-view-btn" type="button">History</button>
|
||||||
|
</div>
|
||||||
|
<!-- Live view -->
|
||||||
|
<div id="sstv-live-view">
|
||||||
|
<div style="margin:0 0 0.5rem;">
|
||||||
|
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
||||||
|
<strong>Slow-Scan Television</strong> — pictures sent as tones, in Martin,
|
||||||
|
Scottie, Robot, PD and Wraase modes. Tune a sideband or FM signal and enable the
|
||||||
|
decoder; the mode is read from the header the sender opens with.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sstv-live-container" style="display:none; margin:0.5rem 0;">
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
|
||||||
|
<strong>Receiving</strong>
|
||||||
|
<small id="sstv-live-info" style="color:var(--text-muted);"></small>
|
||||||
|
</div>
|
||||||
|
<canvas id="sstv-live-canvas" width="320" height="256"
|
||||||
|
style="width:100%; max-width:640px; image-rendering:pixelated; background:#111;"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="sstv-live-latest" style="margin-top:0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- History view -->
|
||||||
|
<div id="sstv-history-view" style="display:none;">
|
||||||
|
<div class="sat-history-controls">
|
||||||
|
<input id="sstv-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Martin, PD120)" />
|
||||||
|
<select id="sstv-sort" class="sat-sort-select">
|
||||||
|
<option value="newest">Newest first</option>
|
||||||
|
<option value="oldest">Oldest first</option>
|
||||||
|
</select>
|
||||||
|
<button id="sstv-clear-btn" type="button" style="font-size:0.8rem;">Clear All</button>
|
||||||
|
</div>
|
||||||
|
<div class="sat-history-header">
|
||||||
|
<span class="sat-col-time">Time</span>
|
||||||
|
<span class="sat-col-type">Mode</span>
|
||||||
|
<span class="sat-col-sat">Size</span>
|
||||||
|
<span class="sat-col-lines">Lines</span>
|
||||||
|
<span class="sat-col-link">Image</span>
|
||||||
|
</div>
|
||||||
|
<div id="sstv-history-list"></div>
|
||||||
|
<small id="sstv-history-count" style="color:var(--text-muted);font-size:0.75rem;">No pictures yet</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="rds-grid">
|
<div class="rds-grid">
|
||||||
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
||||||
@@ -680,19 +749,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
|
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
|
||||||
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="ais-summary">
|
<div class="aprs-filter-row">
|
||||||
<div class="ais-summary-card">
|
<span class="aprs-counts">
|
||||||
<span class="ais-summary-label">Channels</span>
|
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
|
||||||
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
|
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
|
||||||
</div>
|
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||||
<div class="ais-summary-card">
|
</span>
|
||||||
<span class="ais-summary-label">Tracked</span>
|
|
||||||
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
|
|
||||||
</div>
|
|
||||||
<div class="ais-summary-card">
|
|
||||||
<span class="ais-summary-label">Latest</span>
|
|
||||||
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="ais-messages"></div>
|
<div id="ais-messages"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -722,20 +784,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
||||||
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="aprs-summary">
|
|
||||||
<div class="aprs-summary-card">
|
|
||||||
<span class="aprs-summary-label">Frames</span>
|
|
||||||
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
|
|
||||||
</div>
|
|
||||||
<div class="aprs-summary-card">
|
|
||||||
<span class="aprs-summary-label">Visible</span>
|
|
||||||
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
|
|
||||||
</div>
|
|
||||||
<div class="aprs-summary-card">
|
|
||||||
<span class="aprs-summary-label">Latest</span>
|
|
||||||
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aprs-filter-row">
|
<div class="aprs-filter-row">
|
||||||
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
|
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
|
||||||
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||||
@@ -743,11 +791,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
||||||
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||||
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
|
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||||
</div>
|
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||||
<div class="aprs-filter-row">
|
|
||||||
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
||||||
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||||
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
||||||
|
<span class="aprs-counts">
|
||||||
|
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
|
||||||
|
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
|
||||||
|
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="aprs-packets"></div>
|
<div id="aprs-packets"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -757,20 +809,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
||||||
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="aprs-summary">
|
|
||||||
<div class="aprs-summary-card">
|
|
||||||
<span class="aprs-summary-label">Frames</span>
|
|
||||||
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
|
|
||||||
</div>
|
|
||||||
<div class="aprs-summary-card">
|
|
||||||
<span class="aprs-summary-label">Visible</span>
|
|
||||||
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
|
|
||||||
</div>
|
|
||||||
<div class="aprs-summary-card">
|
|
||||||
<span class="aprs-summary-label">Latest</span>
|
|
||||||
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aprs-filter-row">
|
<div class="aprs-filter-row">
|
||||||
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
|
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
|
||||||
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||||
@@ -778,11 +816,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
||||||
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||||
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
|
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||||
</div>
|
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||||
<div class="aprs-filter-row">
|
|
||||||
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
||||||
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||||
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
||||||
|
<span class="aprs-counts">
|
||||||
|
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
|
||||||
|
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
|
||||||
|
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="hf-aprs-packets"></div>
|
<div id="hf-aprs-packets"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,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">
|
||||||
@@ -923,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">
|
||||||
@@ -1028,54 +1025,103 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<template id="tmpl-map">
|
<template id="tmpl-map">
|
||||||
<div id="map-stage">
|
<div id="map-stage">
|
||||||
<div class="map-overlay-panel">
|
<div class="map-overlay-panel">
|
||||||
<div class="map-locator-filter-group">
|
<div class="map-overlay-filters">
|
||||||
<span class="map-locator-filter-label">Filter by</span>
|
<div class="map-locator-filter-group">
|
||||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
<span class="map-locator-filter-label">Filter</span>
|
||||||
</div>
|
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||||
<div class="map-locator-filter-group">
|
</div>
|
||||||
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
<div class="map-locator-filter-group">
|
||||||
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
||||||
</div>
|
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
||||||
<div class="map-locator-filter-group">
|
</div>
|
||||||
<span class="map-locator-filter-label">Rig</span>
|
<div class="map-locator-filter-group">
|
||||||
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
<span class="map-locator-filter-label">Rig</span>
|
||||||
<option value="">All</option>
|
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
||||||
</select>
|
<option value="">All</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="map-locator-filter-group">
|
</div>
|
||||||
<span class="map-locator-filter-label">Search</span>
|
<div class="map-locator-filter-group">
|
||||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
<span class="map-locator-filter-label">History</span>
|
||||||
</div>
|
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||||
<div class="map-locator-filter-group">
|
<option value="15">15 min</option>
|
||||||
<span class="map-locator-filter-label">History</span>
|
<option value="30">30 min</option>
|
||||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
<option value="60">1 hr</option>
|
||||||
<option value="15">15 min</option>
|
<option value="180">3 hrs</option>
|
||||||
<option value="30">30 min</option>
|
<option value="360">6 hrs</option>
|
||||||
<option value="60">1 hr</option>
|
<option value="720">12 hrs</option>
|
||||||
<option value="180">3 hrs</option>
|
<option value="1440">24 hrs</option>
|
||||||
<option value="360">6 hrs</option>
|
</select>
|
||||||
<option value="720">12 hrs</option>
|
</div>
|
||||||
<option value="1440">24 hrs</option>
|
<div class="map-locator-filter-group">
|
||||||
</select>
|
<span class="map-locator-filter-label">Paths</span>
|
||||||
</div>
|
<div class="map-locator-phase-row">
|
||||||
<div class="map-locator-filter-group">
|
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
|
||||||
<span class="map-locator-filter-label">Paths</span>
|
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
|
||||||
<div class="map-locator-phase-row">
|
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
|
</div>
|
||||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
|
</div>
|
||||||
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
|
<!-- Last, so the search field takes whatever the fixed-width
|
||||||
|
groups leave on the bar's final row rather than a sliver. -->
|
||||||
|
<div class="map-locator-filter-group map-filter-grow">
|
||||||
|
<span class="map-locator-filter-label">Search</span>
|
||||||
|
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="map-overlay-actions">
|
||||||
<div class="map-corner-controls">
|
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||||
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
||||||
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
||||||
<div id="aprs-map"></div>
|
<div id="aprs-map"></div>
|
||||||
</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">
|
||||||
@@ -1660,11 +1706,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||||
<div class="decode-history-overlay-card">
|
<div class="history-progress-text">
|
||||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||||
<script defer src="/vendor/leaflet.js"></script>
|
<script defer src="/vendor/leaflet.js"></script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,14 @@ await build({
|
|||||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||||
|
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||||
"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",
|
"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": {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export type RigRxStatus = { sig: number | null, };
|
|||||||
|
|
||||||
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
||||||
|
|
||||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
|
|||||||
/**
|
/**
|
||||||
* Per-virtual-channel RDS snapshots, when available.
|
* Per-virtual-channel RDS snapshots, when available.
|
||||||
*/
|
*/
|
||||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import {
|
|||||||
import { decodeCbor as decodeCborPayload } from "./core/cbor.js";
|
import { decodeCbor as decodeCborPayload } from "./core/cbor.js";
|
||||||
import {
|
import {
|
||||||
TAB_ORDER,
|
TAB_ORDER,
|
||||||
|
type TuneLink,
|
||||||
|
parseTuneLink,
|
||||||
tabFromPath as tabFromPathname,
|
tabFromPath as tabFromPathname,
|
||||||
|
tuneLinkSearch,
|
||||||
updateTabHistory,
|
updateTabHistory,
|
||||||
} from "./features/navigation/routes.js";
|
} from "./features/navigation/routes.js";
|
||||||
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
|
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
|
||||||
@@ -382,7 +385,9 @@ 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;
|
||||||
updateAisBar?(value?: number): void;
|
updateAisBar?(value?: number): void;
|
||||||
updateVdesBar?(value?: number): void;
|
updateVdesBar?(value?: number): void;
|
||||||
updateAprsBar?(value?: number): void;
|
updateAprsBar?(value?: number): void;
|
||||||
@@ -609,6 +614,7 @@ function applyAuthRestrictions() {
|
|||||||
function applyCapabilities(caps: RigCapabilities | null) {
|
function applyCapabilities(caps: RigCapabilities | null) {
|
||||||
if (!caps) return;
|
if (!caps) return;
|
||||||
lastHasTx = !!caps.tx;
|
lastHasTx = !!caps.tx;
|
||||||
|
bandwidthControlSupported = !!caps.filter_controls;
|
||||||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||||||
|
|
||||||
// PTT / TX controls
|
// PTT / TX controls
|
||||||
@@ -712,6 +718,7 @@ const loadingSub = requiredElement("loading-sub");
|
|||||||
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
|
const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||||||
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||||||
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||||||
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||||||
@@ -853,7 +860,7 @@ function currentDecodeHistoryRetentionMs() {
|
|||||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||||
|
|
||||||
window.applyDecodeHistoryRetention = function() {
|
window.applyDecodeHistoryRetention = function() {
|
||||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||||
window.trxPluginRuntime.prune(decoder);
|
window.trxPluginRuntime.prune(decoder);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -880,10 +887,29 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let overviewDrawPending = false;
|
let overviewDrawPending = false;
|
||||||
function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") {
|
// Progress, in the corner. This used to dim the whole page behind a scrim,
|
||||||
|
// which hid the waterfall and the decode panels for as long as the replay ran
|
||||||
|
// — and a replay only happens when there is a backlog worth watching arrive.
|
||||||
|
// `fraction` null leaves the bar indeterminate, for the part where the payload
|
||||||
|
// is still on the wire and there is nothing to count yet.
|
||||||
|
function setDecodeHistoryOverlayVisible(
|
||||||
|
visible: boolean,
|
||||||
|
title = "",
|
||||||
|
sub = "",
|
||||||
|
fraction: number | null = null,
|
||||||
|
) {
|
||||||
if (!decodeHistoryOverlayEl) return;
|
if (!decodeHistoryOverlayEl) return;
|
||||||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||||
|
if (decodeHistoryProgressBarEl) {
|
||||||
|
if (fraction == null) {
|
||||||
|
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||||||
|
decodeHistoryProgressBarEl.style.width = "";
|
||||||
|
} else {
|
||||||
|
delete decodeHistoryOverlayEl.dataset.phase;
|
||||||
|
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,6 +1018,9 @@ let jogAngle = 0;
|
|||||||
let lastClientCount: number | null = null;
|
let lastClientCount: number | null = null;
|
||||||
let lastLocked = false;
|
let lastLocked = false;
|
||||||
let sdrSquelchSupported = false;
|
let sdrSquelchSupported = false;
|
||||||
|
// Whether this rig takes a bandwidth at all — a CAT rig without filter control
|
||||||
|
// has no use for the `bw` of a tune link, in either direction.
|
||||||
|
let bandwidthControlSupported = false;
|
||||||
// ── Previous-state tracking for "B" hotkey ────────────────────────────────────
|
// ── Previous-state tracking for "B" hotkey ────────────────────────────────────
|
||||||
let previousTuneState: PreviousTuneState | null = null;
|
let previousTuneState: PreviousTuneState | null = null;
|
||||||
|
|
||||||
@@ -1404,7 +1433,11 @@ function updateRigSubtitle(activeRigId: string | null) {
|
|||||||
updateDocumentTitle(activeChannelRds());
|
updateDocumentTitle(activeChannelRds());
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames: Record<string, string> = {}) {
|
// `displayNames` omitted means "no news", not "no names". The state updates
|
||||||
|
// carry only the rig ids — /rigs is what knows the names — and this defaulted
|
||||||
|
// to an empty map, so the first state frame after load wiped the names and the
|
||||||
|
// picker and header fell back to the lowercase ids for the rest of the session.
|
||||||
|
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames?: Record<string, string>) {
|
||||||
if (!Array.isArray(rigIds)) return;
|
if (!Array.isArray(rigIds)) return;
|
||||||
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
|
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
|
||||||
// Detect whether the rig list or active rig actually changed so we can
|
// Detect whether the rig list or active rig actually changed so we can
|
||||||
@@ -3243,6 +3276,110 @@ window.buildAisVesselUrl = function(mmsi: unknown) {
|
|||||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tune links
|
||||||
|
//
|
||||||
|
// The address bar is the share link: whatever the dial is on is in the query
|
||||||
|
// string, and a link someone else opens puts the radio there. Read the
|
||||||
|
// incoming link once, at load, before anything of ours rewrites the URL.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const incomingTuneLink: TuneLink = parseTuneLink(window.location.search);
|
||||||
|
// "pending" until the first state arrives, "applying" while the link is being
|
||||||
|
// followed, "live" once the URL is ours to keep up to date.
|
||||||
|
let tuneLinkPhase: "pending" | "applying" | "live" = "pending";
|
||||||
|
let tuneLinkSyncTimer: number | null = null;
|
||||||
|
|
||||||
|
function currentTuneLink(): TuneLink {
|
||||||
|
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0
|
||||||
|
? currentBandwidthHz
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
rig: lastActiveRigId || null,
|
||||||
|
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||||||
|
mode: lastModeName || null,
|
||||||
|
bandwidthHz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The link as someone else would open it. */
|
||||||
|
function tuneLinkUrl(): string {
|
||||||
|
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||||
|
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTuneLinkSync() {
|
||||||
|
if (tuneLinkPhase !== "live") return;
|
||||||
|
// A virtual channel is a tab's own slice, not where the rig is pointed —
|
||||||
|
// publishing it as the rig's link would send someone to another frequency.
|
||||||
|
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||||||
|
if (tuneLinkSyncTimer != null) return;
|
||||||
|
tuneLinkSyncTimer = window.setTimeout(() => {
|
||||||
|
tuneLinkSyncTimer = null;
|
||||||
|
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||||||
|
if (search === window.location.search) return;
|
||||||
|
// replaceState, not push: tuning is not navigation, and a dial sweep would
|
||||||
|
// otherwise bury the back button under a hundred entries.
|
||||||
|
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTuneLink(link: TuneLink) {
|
||||||
|
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||||
|
if (!wanted) return;
|
||||||
|
if (authRole === "rx") {
|
||||||
|
showHint("Read-only session — link not applied", 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Rig first: the rest of the link describes state on that rig.
|
||||||
|
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||||||
|
headerRigSwitchSelect.value = link.rig;
|
||||||
|
await switchRigFromSelect(headerRigSwitchSelect);
|
||||||
|
}
|
||||||
|
// Mode before frequency: the mode change carries a default bandwidth with
|
||||||
|
// it, which an explicit bw in the link then overrides.
|
||||||
|
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||||||
|
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||||||
|
if (known) {
|
||||||
|
modeEl.value = link.mode;
|
||||||
|
await applyModeFromPicker();
|
||||||
|
} else {
|
||||||
|
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (link.freqHz != null) {
|
||||||
|
// setRigFrequency throws on a frequency this rig cannot reach, having
|
||||||
|
// already told the user which one it was.
|
||||||
|
try { setRigFrequency(link.freqHz); } catch { /* reported to the user */ }
|
||||||
|
}
|
||||||
|
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||||||
|
spectrumBwInput.value = String(link.bandwidthHz / 1000);
|
||||||
|
await applyBandwidthFromInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function followTuneLinkOnce() {
|
||||||
|
if (tuneLinkPhase !== "pending") return;
|
||||||
|
tuneLinkPhase = "applying";
|
||||||
|
void applyTuneLink(incomingTuneLink).finally(() => {
|
||||||
|
tuneLinkPhase = "live";
|
||||||
|
scheduleTuneLinkSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTuneLink() {
|
||||||
|
const url = tuneLinkUrl();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showHint("Link copied", 1500);
|
||||||
|
} catch {
|
||||||
|
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareLinkBtn = document.getElementById("header-share-btn") as HTMLButtonElement | null;
|
||||||
|
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => { void copyTuneLink(); });
|
||||||
|
|
||||||
function render(update: AppUpdate) {
|
function render(update: AppUpdate) {
|
||||||
if (!update) return;
|
if (!update) return;
|
||||||
if (update.server_version) serverVersion = update.server_version;
|
if (update.server_version) serverVersion = update.server_version;
|
||||||
@@ -3577,7 +3714,11 @@ function render(update: AppUpdate) {
|
|||||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||||
}
|
}
|
||||||
// WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles).
|
// Image decoders own their own toggle buttons; keep them in step with the
|
||||||
|
// rig state as well as with the click that set it.
|
||||||
|
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||||
|
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||||
|
}
|
||||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||||
}
|
}
|
||||||
@@ -3818,6 +3959,10 @@ function render(update: AppUpdate) {
|
|||||||
swrBar.style.width = "0%";
|
swrBar.style.width = "0%";
|
||||||
swrValue.textContent = "SWR --";
|
swrValue.textContent = "SWR --";
|
||||||
}
|
}
|
||||||
|
// Follow the link that opened the page once there is a rig to point, then
|
||||||
|
// keep the address bar on whatever the dial does next.
|
||||||
|
if (update.status) followTuneLinkOnce();
|
||||||
|
scheduleTuneLinkSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect(delayMs = 1000) {
|
function scheduleReconnect(delayMs = 1000) {
|
||||||
@@ -4705,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);
|
||||||
@@ -4745,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();
|
||||||
}
|
}
|
||||||
@@ -5097,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);
|
||||||
@@ -5384,6 +5532,7 @@ function positionSquelchLine() {
|
|||||||
|
|
||||||
function submitSdrSquelch() {
|
function submitSdrSquelch() {
|
||||||
if (!sdrSquelchSupported) return;
|
if (!sdrSquelchSupported) return;
|
||||||
|
sdrSquelchLocalAt = Date.now();
|
||||||
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
||||||
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
|
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
|
||||||
postPath(
|
postPath(
|
||||||
@@ -5407,6 +5556,9 @@ function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit?
|
|||||||
// the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a
|
// the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a
|
||||||
// narrow span and it simply never closed. Reading the same number the DSP
|
// narrow span and it simply never closed. Reading the same number the DSP
|
||||||
// compares needs no conversion at all.
|
// compares needs no conversion at all.
|
||||||
|
// How long a local squelch change outranks the server's echo of the old one.
|
||||||
|
const SDR_SQUELCH_HOLD_MS = 2_000;
|
||||||
|
let sdrSquelchLocalAt = 0;
|
||||||
const SQUELCH_NOISE_WINDOW_MS = 10_000;
|
const SQUELCH_NOISE_WINDOW_MS = 10_000;
|
||||||
const SQUELCH_NOISE_MARGIN_DB = 5;
|
const SQUELCH_NOISE_MARGIN_DB = 5;
|
||||||
const SQUELCH_MEASURE_MS = 1_500;
|
const SQUELCH_MEASURE_MS = 1_500;
|
||||||
@@ -5452,6 +5604,10 @@ function syncSdrSquelchFromServer(enabled: boolean, thresholdDb: number | null)
|
|||||||
// level would fight the echo of the value the server last confirmed.
|
// level would fight the echo of the value the server last confirmed.
|
||||||
if (squelchDragPointerId !== null) return;
|
if (squelchDragPointerId !== null) return;
|
||||||
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
|
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
|
||||||
|
// Nor just after letting go: state frames are in flight continuously, and
|
||||||
|
// one sent before the new threshold was applied would snap the line back to
|
||||||
|
// where it was dragged from.
|
||||||
|
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
|
||||||
sdrSquelchEnabled = enabled;
|
sdrSquelchEnabled = enabled;
|
||||||
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
|
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
|
||||||
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
||||||
@@ -6420,9 +6576,15 @@ function updateDecodeStatus(text: string) {
|
|||||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Picture decoders produce one message per image and a stream of progress
|
||||||
|
// updates; neither is a spot, and counting them would swamp the statistics.
|
||||||
|
const IMAGE_DECODE_KINDS = new Set([
|
||||||
|
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||||
|
]);
|
||||||
|
|
||||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
@@ -6472,7 +6634,7 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
|
|||||||
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
// Record statistics for restored history messages.
|
// Record statistics for restored history messages.
|
||||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||||
}
|
}
|
||||||
@@ -6494,36 +6656,44 @@ function connectDecode() {
|
|||||||
let historySettled = false;
|
let historySettled = false;
|
||||||
let historyWorkerDone = false;
|
let historyWorkerDone = false;
|
||||||
let historyFallbackStarted = false;
|
let historyFallbackStarted = false;
|
||||||
|
let historyRetried = false;
|
||||||
let historyBatchDrainScheduled = false;
|
let historyBatchDrainScheduled = false;
|
||||||
let historyTotal = 0;
|
let historyTotal = 0;
|
||||||
let historyProcessed = 0;
|
let historyProcessed = 0;
|
||||||
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
const historyGroupQueue: DecodeHistoryGroup[] = [];
|
||||||
const liveBuffer: DecodeMessage[] = [];
|
const liveBuffer: DecodeMessage[] = [];
|
||||||
function flushLiveBuffer() {
|
// Live decodes wait behind the history so the panels stay in order. Letting
|
||||||
|
// them through is not the same as being finished, and conflating the two is
|
||||||
|
// what made a slow history disappear: the safety valve released the buffer
|
||||||
|
// and tore the worker down with it, so whatever had not arrived never did.
|
||||||
|
function releaseLiveBuffer() {
|
||||||
|
if (historySettled) return;
|
||||||
historySettled = true;
|
historySettled = true;
|
||||||
terminateDecodeHistoryWorker();
|
|
||||||
setDecodeHistoryReplayActive(false);
|
|
||||||
setDecodeHistoryOverlayVisible(false);
|
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try { dispatchDecodeMessage(msg); } catch (_) {}
|
try { dispatchDecodeMessage(msg); } catch (_) {}
|
||||||
}
|
}
|
||||||
liveBuffer.length = 0;
|
liveBuffer.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishHistoryReplay() {
|
||||||
|
clearTimeout(historyTimeout);
|
||||||
|
releaseLiveBuffer();
|
||||||
|
terminateDecodeHistoryWorker();
|
||||||
|
setDecodeHistoryReplayActive(false);
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
function updateHistoryReplayOverlay() {
|
function updateHistoryReplayOverlay() {
|
||||||
setDecodeHistoryOverlayVisible(
|
setDecodeHistoryOverlayVisible(
|
||||||
true,
|
true,
|
||||||
"Loading decode history…",
|
"Loading decode history…",
|
||||||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||||||
|
historyTotal > 0 ? historyProcessed / historyTotal : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeFinishHistoryReplay() {
|
function maybeFinishHistoryReplay() {
|
||||||
if (historySettled) return;
|
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||||||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
flushLiveBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pumpDecodeHistoryGroupQueue() {
|
function pumpDecodeHistoryGroupQueue() {
|
||||||
@@ -6585,17 +6755,26 @@ function connectDecode() {
|
|||||||
if (historyFallbackStarted || historySettled) return;
|
if (historyFallbackStarted || historySettled) return;
|
||||||
historyFallbackStarted = true;
|
historyFallbackStarted = true;
|
||||||
loadDecodeHistoryOnMainThread((groups) => {
|
loadDecodeHistoryOnMainThread((groups) => {
|
||||||
clearTimeout(historyTimeout);
|
|
||||||
const total = totalDecodeHistoryMessages(groups);
|
const total = totalDecodeHistoryMessages(groups);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
enqueueDecodeHistoryGroups(groups);
|
enqueueDecodeHistoryGroups(groups);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
finishHistoryReplay();
|
||||||
}
|
}
|
||||||
}, (err: unknown) => {
|
}, (err: unknown) => {
|
||||||
console.error("Decode history fallback failed", err);
|
console.error("Decode history fallback failed", err);
|
||||||
clearTimeout(historyTimeout);
|
// One retry, then say so. Failing silently here is why the history
|
||||||
flushLiveBuffer();
|
// sometimes only turned up on a second reload: nothing asked again and
|
||||||
|
// nothing said anything was missing.
|
||||||
|
if (historyRetried) {
|
||||||
|
showHint("Decode history unavailable", 3000);
|
||||||
|
finishHistoryReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historyRetried = true;
|
||||||
|
historyFallbackStarted = false;
|
||||||
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||||||
|
setTimeout(() => { startDecodeHistoryFallback(); }, 2000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6664,12 +6843,13 @@ function connectDecode() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety valve: if the history fetch hangs, unblock after 20 s.
|
// Safety valve: after 20 s, stop holding live decodes back — but keep
|
||||||
|
// loading. The history is what the operator is waiting for, and dropping it
|
||||||
|
// on the floor at the timeout is not something they can even see happen.
|
||||||
const historyTimeout = setTimeout(() => {
|
const historyTimeout = setTimeout(() => {
|
||||||
if (!historySettled) {
|
if (historySettled) return;
|
||||||
terminateDecodeHistoryWorker();
|
releaseLiveBuffer();
|
||||||
flushLiveBuffer();
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||||||
}
|
|
||||||
}, 20000);
|
}, 20000);
|
||||||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
|
|
||||||
@@ -6692,7 +6872,7 @@ function connectDecode() {
|
|||||||
const wasClosed = source.readyState === 2;
|
const wasClosed = source.readyState === 2;
|
||||||
source.close();
|
source.close();
|
||||||
terminateDecodeHistoryWorker();
|
terminateDecodeHistoryWorker();
|
||||||
if (!historySettled) flushLiveBuffer();
|
if (!historySettled) releaseLiveBuffer();
|
||||||
if (wasClosed) {
|
if (wasClosed) {
|
||||||
updateDecodeStatus("Decode not available (check client audio config)");
|
updateDecodeStatus("Decode not available (check client audio config)");
|
||||||
setTimeout(connectDecode, 10000);
|
setTimeout(connectDecode, 10000);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
|
||||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||||
interface DecodeState { offset: number }
|
interface DecodeState { offset: number }
|
||||||
|
|||||||
+65
-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",
|
||||||
@@ -37,3 +38,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
|
|||||||
if (replace) window.history.replaceState({}, "", nextUrl);
|
if (replace) window.history.replaceState({}, "", nextUrl);
|
||||||
else window.history.pushState({}, "", nextUrl);
|
else window.history.pushState({}, "", nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a shared link says to listen to: `?rig=sdr&f=14074000&mode=USB&bw=3000`. */
|
||||||
|
export interface TuneLink {
|
||||||
|
rig: string | null;
|
||||||
|
freqHz: number | null;
|
||||||
|
mode: string | null;
|
||||||
|
bandwidthHz: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREQ_MULTIPLIER: Readonly<Record<string, number>> = { k: 1e3, m: 1e6, g: 1e9 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a frequency written for a person: bare Hz as the canonical form, but
|
||||||
|
* `7074k` and `14.074M` are what someone typing a link by hand reaches for.
|
||||||
|
* Returns Hz, or null for anything that is not a positive frequency.
|
||||||
|
*/
|
||||||
|
export function parseFrequencyParam(raw: string | null | undefined): number | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||||||
|
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mode names run from FM to VDES; anything else in the URL is not one. */
|
||||||
|
function parseModeParam(raw: string | null | undefined): string | null {
|
||||||
|
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||||||
|
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTuneLink(search: string): TuneLink {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const rig = (params.get("rig") || "").trim();
|
||||||
|
return {
|
||||||
|
rig: rig || null,
|
||||||
|
freqHz: parseFrequencyParam(params.get("f")),
|
||||||
|
mode: parseModeParam(params.get("mode")),
|
||||||
|
bandwidthHz: parseFrequencyParam(params.get("bw")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite the tune parameters of a query string, leaving anything else in it
|
||||||
|
* alone — the address bar is the share link, so it has to keep up with the
|
||||||
|
* dial without discarding whatever else a page put there.
|
||||||
|
*/
|
||||||
|
export function tuneLinkSearch(search: string, link: TuneLink): string {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const set = (key: string, value: string | null) => {
|
||||||
|
if (value == null || value === "") params.delete(key);
|
||||||
|
else params.set(key, value);
|
||||||
|
};
|
||||||
|
const hz = (value: number | null) =>
|
||||||
|
(typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null);
|
||||||
|
set("rig", link.rig);
|
||||||
|
set("f", hz(link.freqHz));
|
||||||
|
set("mode", parseModeParam(link.mode));
|
||||||
|
set("bw", hz(link.bandwidthHz));
|
||||||
|
const text = params.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import type * as Leaflet from "leaflet";
|
import type * as Leaflet from "leaflet";
|
||||||
import { aprsSymbolSprite } from "./plugins/aprs-shared";
|
import { aprsSymbolSprite } from "./plugins/aprs-shared";
|
||||||
|
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
@@ -229,6 +230,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const mapMarkers = new Set<TrxLayer>();
|
const mapMarkers = new Set<TrxLayer>();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
|
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||||
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
|
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
|
||||||
let mapSearchFilter = "";
|
let mapSearchFilter = "";
|
||||||
let mapRigFilter = ""; // "" = all rigs
|
let mapRigFilter = ""; // "" = all rigs
|
||||||
@@ -1078,38 +1081,42 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let helperText = "";
|
const noun = kind === "band" ? "bands" : "sources";
|
||||||
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
|
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
|
||||||
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
|
// Selecting nothing selects everything, for both kinds.
|
||||||
if (kind === "source") {
|
const showingAll = kind === "source"
|
||||||
if (noneSelected) {
|
? sourceKeys.every((k) => !mapFilter[k])
|
||||||
helperText = "All sources visible \u2014 click to filter";
|
: !(selectedSet instanceof Set) || selectedSet.size === 0;
|
||||||
}
|
// An "All" chip carries what a sentence of helper text used to say, in a
|
||||||
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
|
// width the bar can afford, and gives the selection somewhere to be undone.
|
||||||
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
|
const allChip = document.createElement("button");
|
||||||
}
|
allChip.type = "button";
|
||||||
|
allChip.className = "map-locator-chip map-locator-chip-all";
|
||||||
|
if (showingAll) allChip.classList.add("is-active");
|
||||||
|
allChip.dataset.filterKind = kind;
|
||||||
|
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
|
||||||
|
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
|
||||||
|
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
|
||||||
|
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
|
||||||
|
container.appendChild(allChip);
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "map-locator-chip";
|
btn.className = "map-locator-chip";
|
||||||
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
|
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
|
||||||
if (kind === "source" && noneSelected) {
|
// Nothing is filtered out yet, so no chip is dimmed as if it were.
|
||||||
|
if (showingAll) {
|
||||||
btn.classList.add("is-default");
|
btn.classList.add("is-default");
|
||||||
} else if (!isActive) {
|
} else if (!isActive) {
|
||||||
btn.classList.add("is-inactive");
|
btn.classList.add("is-inactive");
|
||||||
}
|
}
|
||||||
|
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
|
||||||
btn.dataset.filterKind = kind;
|
btn.dataset.filterKind = kind;
|
||||||
btn.dataset.filterKey = item.key;
|
btn.dataset.filterKey = item.key;
|
||||||
btn.style.setProperty("--chip-color", item.color);
|
btn.style.setProperty("--chip-color", item.color);
|
||||||
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
if (helperText) {
|
|
||||||
const hint = document.createElement("span");
|
|
||||||
hint.className = "map-locator-empty";
|
|
||||||
hint.textContent = helperText;
|
|
||||||
container.appendChild(hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
|
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
|
||||||
@@ -1233,11 +1240,12 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
|
||||||
|
|
||||||
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
|
||||||
|
// The phase buttons next door already name the dimension; "Show" is the
|
||||||
|
// rest of the sentence, and it keeps the bar's labels a uniform width.
|
||||||
|
choiceLabelEl.textContent = "Show";
|
||||||
if (mapLocatorFilter.phase === "band") {
|
if (mapLocatorFilter.phase === "band") {
|
||||||
choiceLabelEl.textContent = "Visible Bands";
|
|
||||||
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
|
||||||
} else {
|
} else {
|
||||||
choiceLabelEl.textContent = "Visible Sources";
|
|
||||||
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
|
||||||
}
|
}
|
||||||
syncLocatorMarkerStyles();
|
syncLocatorMarkerStyles();
|
||||||
@@ -1636,7 +1644,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
function applyMapOverlayPanelVisibility() {
|
function applyMapOverlayPanelVisibility() {
|
||||||
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
const panel = document.querySelector("#map-stage .map-overlay-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
// Only the filters collapse. The bar itself stays, because it carries the
|
||||||
|
// button that brings them back.
|
||||||
|
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
|
||||||
|
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMapOverlayToggleButton() {
|
function updateMapOverlayToggleButton() {
|
||||||
@@ -1859,7 +1870,14 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const kind = String(chip.dataset.filterKind || "");
|
const kind = String(chip.dataset.filterKind || "");
|
||||||
const key = String(chip.dataset.filterKey || "");
|
const key = String(chip.dataset.filterKey || "");
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
if (key === MAP_FILTER_ALL_KEY) {
|
||||||
|
// Back to no selection at all, which is what shows everything.
|
||||||
|
if (kind === "source") {
|
||||||
|
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]) mapFilter[srcKey] = false;
|
||||||
|
} else {
|
||||||
|
mapLocatorFilter.bands.clear();
|
||||||
|
}
|
||||||
|
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
|
||||||
// toggle the clicked source; when none are selected everything is shown
|
// toggle the clicked source; when none are selected everything is shown
|
||||||
const sourceKey = key as MapFilterKey;
|
const sourceKey = key as MapFilterKey;
|
||||||
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
mapFilter[sourceKey] = !mapFilter[sourceKey];
|
||||||
@@ -2580,18 +2598,26 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
syncDecodeContactPathVisibility();
|
syncDecodeContactPathVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The buttons light up when they are on, so the label need not repeat it —
|
||||||
|
// an "On"/"Off" suffix on each cost the bar most of a row.
|
||||||
function updateMapContactPathsToggle() {
|
function updateMapContactPathsToggle() {
|
||||||
const btn = mapEl("map-contact-paths-toggle");
|
const btn = mapEl("map-contact-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapDecodeContactPathsEnabled
|
||||||
|
? "Directed decode paths are drawn when the target locator is known"
|
||||||
|
: "Directed decode paths are hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMapP2pPathsToggle() {
|
function updateMapP2pPathsToggle() {
|
||||||
const btn = mapEl("map-p2p-paths-toggle");
|
const btn = mapEl("map-p2p-paths-toggle");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
|
|
||||||
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
|
||||||
|
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
|
||||||
|
btn.title = mapP2pRadioPathsEnabled
|
||||||
|
? "TRX paths are drawn from a station popup"
|
||||||
|
: "TRX paths are hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDecodeMapMaintenance() {
|
function scheduleDecodeMapMaintenance() {
|
||||||
@@ -3662,6 +3688,18 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
reverseGeocodeLocation,
|
reverseGeocodeLocation,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everything the decoders already hold goes onto the map now. This module is
|
||||||
|
// lazy -- it arrives when the Map tab is first opened, long after startup
|
||||||
|
// restored the decode history -- and until it does, aprsMapAddStation and
|
||||||
|
// friends are undefined, so every restored position was dropped on the floor.
|
||||||
|
// The map then showed only what arrived live after it loaded, which is why it
|
||||||
|
// took a second reload (with the module cached, and so loaded early enough to
|
||||||
|
// win the race against the history fetch) for the stations to appear.
|
||||||
|
//
|
||||||
|
// The add functions are keyed by callsign/MMSI/point, so replaying costs
|
||||||
|
// nothing on a second call and cannot duplicate a marker.
|
||||||
|
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll();
|
||||||
|
|
||||||
// If the map tab is already visible (direct /map URL), init immediately.
|
// If the map tab is already visible (direct /map URL), init immediately.
|
||||||
autoInitIfVisible();
|
autoInitIfVisible();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,12 +2,22 @@
|
|||||||
//
|
//
|
||||||
// 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[]>> = {
|
||||||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
|
// with it. They used to come only with the map group, which left their
|
||||||
|
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||||||
|
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||||
|
"digital-modes": [
|
||||||
|
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
||||||
|
"/sat.js", "/wefax.js", "/sstv.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-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: [],
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||||
clearQueued() { queued.clear(); },
|
clearQueued() { queued.clear(); },
|
||||||
hasDecoder: (id) => decoders.has(id),
|
hasDecoder: (id) => decoders.has(id),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface AisMessage {
|
|||||||
}
|
}
|
||||||
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
||||||
interface AisBridge {
|
interface AisBridge {
|
||||||
|
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
||||||
@@ -212,8 +213,21 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the message says, in one line: where the vessel is and what it is
|
||||||
|
* doing, or — for the static reports that carry no fix — where it is going. */
|
||||||
|
function aisSummaryText(msg: AisMessage): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
if (motion) parts.push(motion);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
if (route && parts.length < 2) parts.push(route);
|
||||||
|
if (!parts.length) return route || "no position reported";
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
function renderAisRow(msg: AisMessage): HTMLElement {
|
function renderAisRow(msg: AisMessage): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("details");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -227,8 +241,9 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
|||||||
const route = aisRouteText(msg);
|
const route = aisRouteText(msg);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const pos = msg.lat != null && msg.lon != null
|
const pos = msg.lat != null && msg.lon != null
|
||||||
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
|
||||||
: "";
|
: "";
|
||||||
|
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
row.dataset.filterText = [
|
row.dataset.filterText = [
|
||||||
name,
|
name,
|
||||||
msg.mmsi,
|
msg.mmsi,
|
||||||
@@ -243,23 +258,43 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
`<div class="ais-row-head">` +
|
`<summary class="decode-line">` +
|
||||||
`<span class="ais-time">${ts}</span>` +
|
`<span class="ais-time">${escapeAisHtml(ts)}</span>` +
|
||||||
`<span class="ais-call">${nameHtml}</span>` +
|
`<span class="ais-call">${nameHtml}</span>` +
|
||||||
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
|
||||||
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
`</div>` +
|
`<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
|
||||||
`<div class="ais-row-meta">` +
|
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
||||||
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
(distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
|
||||||
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
|
`</summary>` +
|
||||||
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
|
`<div class="decode-expanded">` +
|
||||||
`</div>` +
|
`<div class="decode-expanded-meta">` +
|
||||||
`<div class="ais-row-detail">` +
|
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
||||||
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
`<span>${escapeAisHtml(channel.freqText)}</span>` +
|
||||||
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
|
(route ? `<span>${escapeAisHtml(route)}</span>` : "") +
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
(motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
|
||||||
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||||
|
(pos ? `<span>${pos}</span>` : "") +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-actions">` +
|
||||||
|
(msg.lat != null && msg.lon != null
|
||||||
|
? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>`
|
||||||
|
: "") +
|
||||||
|
(vesselUrl
|
||||||
|
? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>`
|
||||||
|
: "") +
|
||||||
|
`</div>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
|
|
||||||
|
row.querySelectorAll<HTMLElement>("[data-ais-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
|
||||||
|
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
aisWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
applyAisFilterToRow(row);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -354,9 +389,13 @@ function addAisMessage(msg: AisMessage): void {
|
|||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
|
|
||||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(msg);
|
||||||
aisWindow.aisMapAddVessel(msg);
|
}
|
||||||
}
|
|
||||||
|
/** Hands a positioned message to the map, if the map module is loaded yet. */
|
||||||
|
function plotAisMessage(msg: AisMessage): void {
|
||||||
|
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
||||||
@@ -379,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
plotAisMessage(next);
|
||||||
aisWindow.aisMapAddVessel(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -427,4 +464,6 @@ updateAisSummary();
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export interface AprsPacket {
|
|||||||
symbol_code?: string | null;
|
symbol_code?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAprsHtml(value: string): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
||||||
const type = (packet.type ?? "").toLowerCase();
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
const info = (packet.info ?? "").toLowerCase();
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
@@ -174,6 +182,13 @@ export function aprsSymbolSprite(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An empty slot of the symbol's size, so a frame without one still lines up
|
||||||
|
* with the frames around it in the list. */
|
||||||
|
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
||||||
|
return renderLocalAprsSymbol(packet, escapeHtml)
|
||||||
|
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
||||||
@@ -205,3 +220,174 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs
|
|||||||
symbolCode: packet.symbol_code ?? null,
|
symbolCode: packet.symbol_code ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Payload summaries ──────────────────────────────────────────────────────
|
||||||
|
// APRS packs its meaning into the information field with a set of one-character
|
||||||
|
// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The
|
||||||
|
// list showed that field as it arrives on the air, so reading a weather report
|
||||||
|
// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line
|
||||||
|
// of plain text for the common types and leave the raw field to the expanded
|
||||||
|
// view, which is still the authority when a summary cannot be made.
|
||||||
|
|
||||||
|
/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */
|
||||||
|
function fahrenheitToCelsius(fahrenheit: number): number {
|
||||||
|
return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */
|
||||||
|
function summarizeAprsWeather(info: string): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const temperature = /t(-?\d{2,3})/.exec(info);
|
||||||
|
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
|
||||||
|
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
|
||||||
|
if (wind) {
|
||||||
|
const gust = /g(\d{3})/.exec(info);
|
||||||
|
const knots = Number(wind[2]);
|
||||||
|
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
|
||||||
|
}
|
||||||
|
const humidity = /h(\d{2})/.exec(info);
|
||||||
|
if (humidity) {
|
||||||
|
const value = Number(humidity[1]);
|
||||||
|
parts.push(`${value === 0 ? 100 : value}% RH`);
|
||||||
|
}
|
||||||
|
const pressure = /b(\d{5})/.exec(info);
|
||||||
|
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
|
||||||
|
const rain = /r(\d{3})/.exec(info);
|
||||||
|
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */
|
||||||
|
function summarizeAprsTelemetry(info: string): string | null {
|
||||||
|
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
|
||||||
|
if (!match?.[2]) return null;
|
||||||
|
const channels = match[2].split(",").filter((value) => value.length > 0);
|
||||||
|
const bits = match[3] ? ` · bits ${match[3]}` : "";
|
||||||
|
return `#${match[1]} · ${channels.join(" ")}${bits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `:DEST :text{01` → addressed message text. */
|
||||||
|
function summarizeAprsMessage(info: string): string | null {
|
||||||
|
const match = /^:([^:]{9}):(.*)$/.exec(info);
|
||||||
|
if (!match?.[1] || match[2] == null) return null;
|
||||||
|
const addressee = match[1].trim();
|
||||||
|
const text = match[2].replace(/\{\d+\s*$/, "").trim();
|
||||||
|
return `→ ${addressee}: ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Course/speed appended to a position, as `088/036`. */
|
||||||
|
function summarizeAprsCourseSpeed(info: string): string | null {
|
||||||
|
const match = /(\d{3})\/(\d{3})/.exec(info);
|
||||||
|
if (!match) return null;
|
||||||
|
const knots = Number(match[2]);
|
||||||
|
if (knots === 0) return null;
|
||||||
|
return `${Number(match[1])}° ${knots} kt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatever a frame is worth saying in one line, or null to fall back to raw. */
|
||||||
|
export function summarizeAprsPayload(packet: AprsPacket): string | null {
|
||||||
|
const info = packet.info ?? "";
|
||||||
|
if (!info) return null;
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
if (category === "message") return summarizeAprsMessage(info);
|
||||||
|
if (category === "weather") return summarizeAprsWeather(info);
|
||||||
|
if (category === "telemetry") return summarizeAprsTelemetry(info);
|
||||||
|
if (category === "position") {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (packet.lat != null && packet.lon != null) {
|
||||||
|
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
const courseSpeed = summarizeAprsCourseSpeed(info);
|
||||||
|
if (courseSpeed) parts.push(courseSpeed);
|
||||||
|
// Whatever the station wrote after the position report.
|
||||||
|
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
|
||||||
|
if (comment && comment.length <= 60) parts.push(comment);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
// Status and anything else: the text it carries, minus its type character.
|
||||||
|
const text = info.replace(/^[>;<?]/, "").trim();
|
||||||
|
return text.length ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame row ──────────────────────────────────────────────────────────────
|
||||||
|
// Shared by the APRS and HF APRS lists, which had a copy each of the same
|
||||||
|
// forty lines of markup and drifted only by one badge.
|
||||||
|
|
||||||
|
export interface AprsRowOptions {
|
||||||
|
/** Marks the newest frame so it can flash on arrival. */
|
||||||
|
fresh?: boolean;
|
||||||
|
/** Leading badge, e.g. the band a copy of this list is dedicated to. */
|
||||||
|
badge?: string;
|
||||||
|
/** Distance from the receiver, already formatted, or "" to leave it out. */
|
||||||
|
distance?: string;
|
||||||
|
/** Opens the map on a frame's position. */
|
||||||
|
onMap?: (lat: number, lon: number) => void;
|
||||||
|
/** Puts a frame's coordinates on the clipboard. */
|
||||||
|
onCopy?: (text: string, button: HTMLElement) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
|
||||||
|
const row = document.createElement("details");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (options.fresh) row.classList.add("aprs-packet-new");
|
||||||
|
|
||||||
|
const time = packet._ts
|
||||||
|
|| new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
const summary = summarizeAprsPayload(packet);
|
||||||
|
const hasPosition = packet.lat != null && packet.lon != null;
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
`<summary class="decode-line">` +
|
||||||
|
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
|
||||||
|
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
|
||||||
|
renderAprsSymbolSlot(packet, escapeAprsHtml) +
|
||||||
|
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
|
||||||
|
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
|
||||||
|
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
|
||||||
|
`<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
|
||||||
|
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
|
||||||
|
(options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
|
||||||
|
`</summary>` +
|
||||||
|
`<div class="decode-expanded">` +
|
||||||
|
`<div class="decode-expanded-meta">` +
|
||||||
|
`<span>>${escapeAprsHtml(packet.destCall || "--")}</span>` +
|
||||||
|
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
|
||||||
|
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
|
||||||
|
`<span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` +
|
||||||
|
(hasPosition
|
||||||
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">`
|
||||||
|
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
|
||||||
|
: "") +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
|
||||||
|
(packet.info_bytes?.length
|
||||||
|
? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
|
||||||
|
: "") +
|
||||||
|
`<div class="aprs-row-actions">` +
|
||||||
|
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
|
||||||
|
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
|
||||||
|
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>`;
|
||||||
|
|
||||||
|
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow,
|
||||||
renderLocalAprsSymbol,
|
|
||||||
type AprsPacket,
|
type AprsPacket,
|
||||||
type AprsTypeFilter,
|
type AprsTypeFilter,
|
||||||
} from "./aprs-shared";
|
} from "./aprs-shared";
|
||||||
@@ -141,93 +138,24 @@ function updateAprsChipState() {
|
|||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
async function copyAprsCoords(text: string): Promise<void> {
|
||||||
const row = document.createElement("div");
|
try {
|
||||||
row.className = "aprs-packet";
|
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!clipboard) return;
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
await clipboard.writeText(text);
|
||||||
|
showAprsHint("Coordinates copied", 1200);
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
} catch {
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
showAprsHint("Copy failed", 1500);
|
||||||
const category = aprsPacketCategory(pkt);
|
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null
|
|
||||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
|
||||||
: "";
|
|
||||||
const distance = aprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
|
|
||||||
row.innerHTML =
|
|
||||||
`<div class="aprs-row-head">` +
|
|
||||||
`<span class="aprs-time">${ts}</span>` +
|
|
||||||
symbolHtml +
|
|
||||||
`<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
|
|
||||||
`<span>>${escapeAprsHtml(pkt.destCall || "")}</span>` +
|
|
||||||
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
|
|
||||||
pathBadge +
|
|
||||||
crcBadge +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-meta">` +
|
|
||||||
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
|
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
|
|
||||||
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-detail">` +
|
|
||||||
`<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-actions">` +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
|
||||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
|
||||||
`</div>` +
|
|
||||||
`<details class="aprs-details">` +
|
|
||||||
`<summary>Details</summary>` +
|
|
||||||
`<div class="aprs-details-grid">` +
|
|
||||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
|
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</details>`;
|
|
||||||
|
|
||||||
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
aprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", () => { void (async () => {
|
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
|
||||||
try {
|
|
||||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
|
||||||
if (clipboard) {
|
|
||||||
await clipboard.writeText(raw);
|
|
||||||
showAprsHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showAprsHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})(); });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||||
|
return renderAprsPacketRow(pkt, {
|
||||||
|
fresh: isFresh,
|
||||||
|
distance: aprsDistanceText(pkt),
|
||||||
|
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||||
|
onCopy: (text) => { void copyAprsCoords(text); },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsHistory() {
|
function renderAprsHistory() {
|
||||||
@@ -301,6 +229,12 @@ function pruneAprsHistoryView(): void {
|
|||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a positioned packet to the map, if the map module is loaded yet. */
|
||||||
|
function plotAprsPacket(pkt: AprsPacket): void {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
|
||||||
|
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
|
|
||||||
function addAprsPacket(pkt: AprsPacket): void {
|
function addAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
@@ -309,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void {
|
|||||||
aprsPacketHistory.unshift(pkt);
|
aprsPacketHistory.unshift(pkt);
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
|
|
||||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(pkt);
|
||||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
|
||||||
@@ -332,9 +264,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
|
|||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
plotAprsPacket(next);
|
||||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
|
||||||
}
|
|
||||||
if (next.crcOk) hasCrcOk = true;
|
if (next.crcOk) hasCrcOk = true;
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -406,4 +336,6 @@ renderAprsHistory();
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsInfo,
|
renderAprsPacketRow,
|
||||||
renderLocalAprsSymbol,
|
|
||||||
type AprsPacket,
|
type AprsPacket,
|
||||||
type AprsTypeFilter,
|
type AprsTypeFilter,
|
||||||
} from "./aprs-shared";
|
} from "./aprs-shared";
|
||||||
@@ -27,7 +24,6 @@ interface HfAprsBridge {
|
|||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
}
|
}
|
||||||
const hfAprsWindow = window as unknown as HfAprsBridge;
|
const hfAprsWindow = window as unknown as HfAprsBridge;
|
||||||
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
|
||||||
|
|
||||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
@@ -128,95 +124,27 @@ function updateHfAprsChipState() {
|
|||||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HF traffic goes in the same row as VHF, marked with the band it came in on.
|
||||||
|
// This was a second copy of the same forty lines of markup.
|
||||||
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
||||||
const row = document.createElement("div");
|
return renderAprsPacketRow(pkt, {
|
||||||
row.className = "aprs-packet";
|
fresh: isFresh,
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
badge: "HF",
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
distance: hfAprsDistanceText(pkt),
|
||||||
|
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
onCopy: (text: string) => { void copyHfAprsCoords(text); },
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
|
||||||
const category = aprsPacketCategory(pkt);
|
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null
|
|
||||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
|
||||||
: "";
|
|
||||||
const distance = hfAprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
|
|
||||||
row.innerHTML =
|
|
||||||
`<div class="aprs-row-head">` +
|
|
||||||
`<span class="aprs-time">${ts}</span>` +
|
|
||||||
hfBadge +
|
|
||||||
symbolHtml +
|
|
||||||
`<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span>` +
|
|
||||||
`<span>>${escapeHfAprsHtml(pkt.destCall || "")}</span>` +
|
|
||||||
`<span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` +
|
|
||||||
pathBadge +
|
|
||||||
crcBadge +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-meta">` +
|
|
||||||
`<span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` +
|
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") +
|
|
||||||
`<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-detail">` +
|
|
||||||
`<span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-actions">` +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
|
||||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
|
||||||
`</div>` +
|
|
||||||
`<details class="aprs-details">` +
|
|
||||||
`<summary>Details</summary>` +
|
|
||||||
`<div class="aprs-details-grid">` +
|
|
||||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span>` +
|
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</details>`;
|
|
||||||
|
|
||||||
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
hfAprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
async function copyHfAprsCoords(text: string): Promise<void> {
|
||||||
if (copyBtn) {
|
try {
|
||||||
copyBtn.addEventListener("click", () => { void (async () => {
|
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
if (!clipboard) return;
|
||||||
try {
|
await clipboard.writeText(text);
|
||||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
hostCore.showHint("Coordinates copied", 1200);
|
||||||
if (clipboard) {
|
} catch {
|
||||||
await clipboard.writeText(raw);
|
hostCore.showHint("Copy failed", 1500);
|
||||||
hostCore.showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hostCore.showHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})(); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHfAprsHistory() {
|
function renderHfAprsHistory() {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface DecoderPlugin<TMessage = unknown> {
|
|||||||
restore?(messages: TMessage[]): void;
|
restore?(messages: TMessage[]): void;
|
||||||
reset?(): void;
|
reset?(): void;
|
||||||
prune?(): void;
|
prune?(): void;
|
||||||
|
/** Replay everything the plugin is holding onto the map. Called when the map
|
||||||
|
* module attaches, which can happen long after the decodes arrived. */
|
||||||
|
syncMap?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrxPluginRuntime {
|
export interface TrxPluginRuntime {
|
||||||
@@ -19,6 +22,7 @@ export interface TrxPluginRuntime {
|
|||||||
reset(id: string): boolean;
|
reset(id: string): boolean;
|
||||||
resetAll(): void;
|
resetAll(): void;
|
||||||
prune(id: string): boolean;
|
prune(id: string): boolean;
|
||||||
|
syncMapAll(): void;
|
||||||
clearQueued(): void;
|
clearQueued(): void;
|
||||||
hasDecoder(id: string): boolean;
|
hasDecoder(id: string): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// sstv.ts — SSTV decoder panel.
|
||||||
|
//
|
||||||
|
// Live view: decoder state, a canvas the picture fills row by row as it
|
||||||
|
// arrives, and a card for the last one received. History view: a filterable
|
||||||
|
// table of received pictures with thumbnails.
|
||||||
|
//
|
||||||
|
// Watching a picture build up is most of the appeal of the mode, so rows are
|
||||||
|
// painted as they arrive rather than waiting for the frame to finish — a
|
||||||
|
// transmission takes between 36 seconds and two minutes.
|
||||||
|
|
||||||
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface SstvImage {
|
||||||
|
ts_ms?: number;
|
||||||
|
vis?: number;
|
||||||
|
mode?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
lines?: number;
|
||||||
|
complete?: boolean;
|
||||||
|
path?: string;
|
||||||
|
_tsMs?: number;
|
||||||
|
_ts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SstvProgress {
|
||||||
|
state?: string;
|
||||||
|
mode?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
line?: number;
|
||||||
|
line_data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SstvBridge {
|
||||||
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
|
syncSstvToggle?: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sstvWindow = window as unknown as SstvBridge & PluginRuntimeWindow;
|
||||||
|
|
||||||
|
const sstvDom = {
|
||||||
|
status: document.getElementById("sstv-status"),
|
||||||
|
liveView: document.getElementById("sstv-live-view"),
|
||||||
|
historyView: document.getElementById("sstv-history-view"),
|
||||||
|
liveContainer: document.getElementById("sstv-live-container"),
|
||||||
|
liveInfo: document.getElementById("sstv-live-info"),
|
||||||
|
liveCanvas: document.getElementById("sstv-live-canvas") as HTMLCanvasElement | null,
|
||||||
|
liveLatest: document.getElementById("sstv-live-latest"),
|
||||||
|
historyList: document.getElementById("sstv-history-list"),
|
||||||
|
historyCount: document.getElementById("sstv-history-count"),
|
||||||
|
filterInput: document.getElementById("sstv-filter") as HTMLInputElement | null,
|
||||||
|
sortSelect: document.getElementById("sstv-sort") as HTMLSelectElement | null,
|
||||||
|
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("sstv-view-history"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const SSTV_MAX_IMAGES = 100;
|
||||||
|
|
||||||
|
let sstvHistory: SstvImage[] = [];
|
||||||
|
let liveCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
let liveMode = "";
|
||||||
|
let liveHeight = 0;
|
||||||
|
let liveRows = 0;
|
||||||
|
let activeView: "live" | "history" = "live";
|
||||||
|
let filterText = "";
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function retentionMs(): number {
|
||||||
|
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHistory() {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown): string {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleUi(key: string, job: () => void): void {
|
||||||
|
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The URL the server serves a saved picture from, given its stored path. */
|
||||||
|
function imageUrl(image: SstvImage): string | null {
|
||||||
|
if (!image.path) return null;
|
||||||
|
const filename = image.path.split(/[\\/]/).pop();
|
||||||
|
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(data: string): Uint8Array {
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function switchView(view: "live" | "history"): void {
|
||||||
|
activeView = view;
|
||||||
|
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||||
|
button?.classList.remove("sat-view-active");
|
||||||
|
}
|
||||||
|
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||||
|
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderHistoryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvDom.viewLiveBtn?.addEventListener("click", () => { switchView("live"); });
|
||||||
|
sstvDom.viewHistoryBtn?.addEventListener("click", () => { switchView("history"); });
|
||||||
|
|
||||||
|
// ── Live canvas ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Start a new picture: size the canvas to the mode and clear it. */
|
||||||
|
function beginPicture(mode: string, width: number, height: number): void {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!canvas || width <= 0 || height <= 0) return;
|
||||||
|
liveMode = mode;
|
||||||
|
liveHeight = height;
|
||||||
|
liveRows = 0;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
liveCtx = canvas.getContext("2d");
|
||||||
|
if (!liveCtx) return;
|
||||||
|
// Mid-grey, not black: the rows below the one arriving have not been
|
||||||
|
// received, which is a different thing from having been received as black.
|
||||||
|
liveCtx.fillStyle = "#606060";
|
||||||
|
liveCtx.fillRect(0, 0, width, height);
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||||
|
updateLiveInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveInfo(): void {
|
||||||
|
if (!sstvDom.liveInfo) return;
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||||
|
sstvDom.liveInfo.textContent = liveMode
|
||||||
|
? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint one row of RGB triples at its own line number. */
|
||||||
|
function paintRow(line: number, rgb: Uint8Array): void {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!liveCtx || !canvas) return;
|
||||||
|
const width = canvas.width;
|
||||||
|
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||||
|
const row = liveCtx.createImageData(width, 1);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||||
|
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||||
|
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||||
|
row.data[x * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
liveCtx.putImageData(row, 0, line);
|
||||||
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server messages ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onProgress(msg: SstvProgress): void {
|
||||||
|
if (msg.state) {
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
|
// A state update carries the geometry; a row update carries only the row.
|
||||||
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||||
|
const line = msg.line;
|
||||||
|
const rgb = decodeBase64(msg.line_data);
|
||||||
|
scheduleUi(`sstv-row-${line}`, () => {
|
||||||
|
paintRow(line, rgb);
|
||||||
|
updateLiveInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImage(msg: SstvImage): void {
|
||||||
|
const image: SstvImage = { ...msg };
|
||||||
|
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||||
|
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||||
|
sstvHistory.push(image);
|
||||||
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
|
pruneHistory();
|
||||||
|
|
||||||
|
if (sstvDom.status) {
|
||||||
|
sstvDom.status.textContent = image.complete
|
||||||
|
? `Received ${image.mode ?? "picture"}`
|
||||||
|
: `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
|
}
|
||||||
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderLatestCard(): void {
|
||||||
|
if (!sstvDom.liveLatest) return;
|
||||||
|
const latest = sstvHistory[sstvHistory.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = imageUrl(latest);
|
||||||
|
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||||
|
const state = latest.complete ? "complete" : "partial";
|
||||||
|
sstvDom.liveLatest.innerHTML = `
|
||||||
|
<div class="sat-latest-card">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||||
|
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||||
|
</div>
|
||||||
|
${url
|
||||||
|
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||||
|
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||||
|
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||||
|
</a>`
|
||||||
|
: ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredHistory(): SstvImage[] {
|
||||||
|
const text = filterText.trim().toLowerCase();
|
||||||
|
const matching = text
|
||||||
|
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||||
|
: sstvHistory.slice();
|
||||||
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryTable(): void {
|
||||||
|
if (!sstvDom.historyList) return;
|
||||||
|
pruneHistory();
|
||||||
|
const rows = filteredHistory();
|
||||||
|
sstvDom.historyList.innerHTML = rows
|
||||||
|
.map((image) => {
|
||||||
|
const url = imageUrl(image);
|
||||||
|
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||||
|
const lines = image.complete
|
||||||
|
? String(image.lines ?? 0)
|
||||||
|
: `${image.lines ?? 0} (partial)`;
|
||||||
|
return `<div class="sat-history-row">
|
||||||
|
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||||
|
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||||
|
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||||
|
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||||
|
<span class="sat-col-link">${url
|
||||||
|
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>`
|
||||||
|
: "--"}</span>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
if (sstvDom.historyCount) {
|
||||||
|
sstvDom.historyCount.textContent = rows.length
|
||||||
|
? `${rows.length} picture${rows.length === 1 ? "" : "s"}`
|
||||||
|
: "No pictures yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvDom.filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = sstvDom.filterInput?.value ?? "";
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
sstvDom.sortSelect?.addEventListener("change", () => { renderHistoryTable(); });
|
||||||
|
|
||||||
|
// ── Decoder history plumbing ────────────────────────────────────────
|
||||||
|
|
||||||
|
function restoreHistory(entries: unknown[]): void {
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
for (const entry of entries) onImage(entry as SstvImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHistoryView(): void {
|
||||||
|
sstvHistory = [];
|
||||||
|
liveRows = 0;
|
||||||
|
liveMode = "";
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled: boolean) {
|
||||||
|
const button = sstvDom.toggleBtn as HTMLButtonElement | null;
|
||||||
|
if (!button) return;
|
||||||
|
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||||
|
button.setAttribute("aria-pressed", String(enabled));
|
||||||
|
button.classList.toggle("is-active", enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (sstvDom.toggleBtn) {
|
||||||
|
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_sstv_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_sstv_decode");
|
||||||
|
resetHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLatestCard();
|
||||||
|
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv",
|
||||||
|
onMessage: onImage as (msg: unknown) => void,
|
||||||
|
restore: restoreHistory,
|
||||||
|
prune: renderHistoryTable,
|
||||||
|
reset: resetHistoryView,
|
||||||
|
});
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv_progress",
|
||||||
|
onMessage: onProgress as (msg: unknown) => void,
|
||||||
|
});
|
||||||
@@ -320,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -349,13 +347,17 @@ if (vdesFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a positioned message to the map, if the map module is loaded yet. */
|
||||||
|
function plotVdesMessage(msg: VdesMessage): void {
|
||||||
|
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
||||||
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
|
}
|
||||||
|
|
||||||
function onServerVdes(msg: VdesMessage): void {
|
function onServerVdes(msg: VdesMessage): void {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
plotVdesMessage(next);
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneVdesHistoryView(): void {
|
function pruneVdesHistoryView(): void {
|
||||||
@@ -372,4 +374,6 @@ updateVdesSummary();
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -327,7 +327,9 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
|
|
||||||
// Secondary controls, in the order they leave the bar when it gets tight.
|
// Secondary controls, in the order they leave the bar when it gets tight.
|
||||||
// Audio, record and the rig picker are the operating controls and stay.
|
// Audio, record and the rig picker are the operating controls and stay.
|
||||||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
const overflowOrder = [
|
||||||
|
".operator-layout-picker", ".header-style-pick", "#header-share-btn", "#theme-toggle",
|
||||||
|
];
|
||||||
|
|
||||||
// Anchored at paint time in fixed coordinates. An absolutely positioned
|
// Anchored at paint time in fixed coordinates. An absolutely positioned
|
||||||
// dropdown is clipped by any scrolling ancestor and trapped inside the
|
// dropdown is clipped by any scrolling ancestor and trapped inside the
|
||||||
@@ -497,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");
|
||||||
@@ -539,7 +541,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
select.setAttribute("aria-label", "Decoder view");
|
select.setAttribute("aria-label", "Decoder view");
|
||||||
const groups: Array<[string, string[]]> = [
|
const groups: Array<[string, string[]]> = [
|
||||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]],
|
||||||
];
|
];
|
||||||
groups.forEach(([label, ids]) => {
|
groups.forEach(([label, ids]) => {
|
||||||
const group = document.createElement("optgroup");
|
const group = document.createElement("optgroup");
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ try {
|
|||||||
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
|
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
|
||||||
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
|
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
|
||||||
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
|
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
|
||||||
|
|
||||||
|
// Each decoder's list fills its panel. FT8, FT4, FT2 and WSPR size against
|
||||||
|
// the panel with flex, so a panel sized to its own content collapsed them to
|
||||||
|
// their 120px minimum with the rest of the page left empty; the marine lists
|
||||||
|
// were sized by a viewport formula that stopped matching when the panel
|
||||||
|
// changed shape.
|
||||||
|
for (const [subtab, list] of [["ft8", "ft8-messages"], ["wspr", "wspr-messages"],
|
||||||
|
["ais", "ais-messages"], ["aprs", "aprs-packets"], ["hf-aprs", "hf-aprs-packets"]]) {
|
||||||
|
await page.locator(`.sub-tab[data-subtab="${subtab}"]`).click();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const filled = await page.evaluate((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
const panel = element.closest(".sub-tab-panel");
|
||||||
|
return {
|
||||||
|
list: Math.round(element.getBoundingClientRect().height),
|
||||||
|
panel: Math.round(panel.getBoundingClientRect().height),
|
||||||
|
scrolls: getComputedStyle(element).overflowY,
|
||||||
|
};
|
||||||
|
}, list);
|
||||||
|
assert.ok(filled.list > filled.panel * 0.6,
|
||||||
|
`${subtab}: the list is ${filled.list}px in a ${filled.panel}px panel`);
|
||||||
|
assert.equal(filled.scrolls, "auto", `${subtab}: the list does not scroll on its own`);
|
||||||
|
}
|
||||||
await page.locator('.tab[data-tab="main"]').click();
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
@@ -147,6 +170,20 @@ try {
|
|||||||
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
|
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
|
||||||
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
|
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
|
||||||
|
|
||||||
|
// Rig names, and they have to survive the state stream. The updates carry
|
||||||
|
// only rig ids — /rigs is what knows the names — and applying one used to
|
||||||
|
// clear the names, so the picker and the header fell back to the lowercase
|
||||||
|
// ids a second after load and stayed there.
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const rigLabels = await page.evaluate(() => ({
|
||||||
|
options: [...document.getElementById("header-rig-switch-select").options].map((o) => o.textContent),
|
||||||
|
subtitle: document.getElementById("rig-subtitle").textContent,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(rigLabels.options, ["Primary fixture", "Secondary fixture"],
|
||||||
|
`the picker reads ${JSON.stringify(rigLabels.options)}`);
|
||||||
|
assert.equal(rigLabels.subtitle, "Rig: Primary fixture",
|
||||||
|
`the header reads "${rigLabels.subtitle}"`);
|
||||||
|
|
||||||
const rigPicker = page.locator("#header-rig-switch-select");
|
const rigPicker = page.locator("#header-rig-switch-select");
|
||||||
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
|
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
|
||||||
await rigPicker.selectOption("rig-b");
|
await rigPicker.selectOption("rig-b");
|
||||||
@@ -339,6 +376,18 @@ try {
|
|||||||
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
|
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
|
||||||
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
|
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
|
||||||
|
|
||||||
|
// The frequency readouts are typed into, so the browser remembers what went
|
||||||
|
// in and offers it back in a dropdown over the reading — Edge does this by
|
||||||
|
// default. Nothing here wants to be autofilled from what was tuned last week.
|
||||||
|
const autofill = await page.evaluate(() => ["freq", "center-freq"].map((id) => {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
return { id, autocomplete: input?.getAttribute("autocomplete") ?? null };
|
||||||
|
}));
|
||||||
|
for (const field of autofill) {
|
||||||
|
assert.equal(field.autocomplete, "off",
|
||||||
|
`#${field.id} offers autofill (autocomplete=${field.autocomplete})`);
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await fixture.close();
|
await fixture.close();
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// What happens to a decode after it arrives: the panel on its tab, the mini
|
||||||
|
// view over the waterfall, the marker on the map, and the link between them.
|
||||||
|
// Nothing exercised this before — the fixture served an empty decode stream —
|
||||||
|
// which is how the map links came to be broken for every decoder at once.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, getComputedStyle, window, location, requestAnimationFrame, MutationObserver */
|
||||||
|
|
||||||
|
const VESSEL = {
|
||||||
|
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
|
||||||
|
callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
const BEACON = {
|
||||||
|
type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon",
|
||||||
|
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||||
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
|
||||||
|
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||||
|
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// The decoders with panels on this tab have to load with it. They used to
|
||||||
|
// come only with the map group, so these panels stayed empty — decodes
|
||||||
|
// queued in the plugin runtime — until something opened the Map tab.
|
||||||
|
const panels = await page.evaluate(() => ({
|
||||||
|
ais: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
|
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||||
|
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
||||||
|
mapLoaded: !!window.trx.modules.map,
|
||||||
|
}));
|
||||||
|
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
||||||
|
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||||
|
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
||||||
|
|
||||||
|
// The mini view rides over the waterfall on the radio page.
|
||||||
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const miniView = await page.evaluate(() => {
|
||||||
|
const bar = document.getElementById("ais-bar-overlay");
|
||||||
|
return {
|
||||||
|
shown: getComputedStyle(bar).display !== "none",
|
||||||
|
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||||
|
names: bar.textContent.includes("NEDERLAND"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
||||||
|
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
||||||
|
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
||||||
|
|
||||||
|
// Following the pin: the map opens, on the vessel. This is the path that was
|
||||||
|
// broken for every decoder — the module that owned the navigation had not
|
||||||
|
// been loaded, so the pin did nothing at all.
|
||||||
|
await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click();
|
||||||
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const followed = await page.evaluate(() => {
|
||||||
|
const centre = window.trx.modules.map?.aprsMap?.getCenter?.();
|
||||||
|
return {
|
||||||
|
path: location.pathname,
|
||||||
|
lat: centre ? Number(centre.lat.toFixed(2)) : null,
|
||||||
|
lon: centre ? Number(centre.lng.toFixed(2)) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`);
|
||||||
|
assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`);
|
||||||
|
assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`);
|
||||||
|
|
||||||
|
// Both decoders put their own marker on it.
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const markers = await page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const size = (collection) => (collection instanceof Map
|
||||||
|
? collection.size
|
||||||
|
: Object.keys(collection ?? {}).length);
|
||||||
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
|
});
|
||||||
|
assert.ok(markers.ais > 0, "the vessel never reached the map");
|
||||||
|
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored history, which is what is on screen a second after a page load. The
|
||||||
|
// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
|
||||||
|
// left the client on its retry path and the history path untested.
|
||||||
|
const HISTORY_AIS = 900;
|
||||||
|
const HISTORY_APRS = 300;
|
||||||
|
const historyFixture = await startWebFixture({
|
||||||
|
spectrum: true,
|
||||||
|
mode: "AIS",
|
||||||
|
history: {
|
||||||
|
ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
|
||||||
|
mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
|
||||||
|
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||||
|
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||||
|
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||||
|
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||||
|
lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a",
|
||||||
|
ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const replay = await startBrowser(chromium);
|
||||||
|
|
||||||
|
// Installed before the page's own scripts, so nothing can be missed: every
|
||||||
|
// time the progress element becomes visible, its geometry is recorded.
|
||||||
|
await replay.page.addInitScript(() => {
|
||||||
|
window.__historyProgressSamples = [];
|
||||||
|
const watch = () => {
|
||||||
|
const element = document.getElementById("decode-history-overlay");
|
||||||
|
if (!element) { requestAnimationFrame(watch); return; }
|
||||||
|
const sample = () => {
|
||||||
|
if (element.classList.contains("is-hidden")) return;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
window.__historyProgressSamples.push({
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
sample();
|
||||||
|
};
|
||||||
|
watch();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replay.page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
// While it loads, the operator can still see the radio. This used to be a
|
||||||
|
// full-screen scrim over everything for as long as the replay ran.
|
||||||
|
//
|
||||||
|
// Watched from inside the page rather than polled from here: a fast replay
|
||||||
|
// can start and finish between two polls, and then the test reports that no
|
||||||
|
// progress was ever shown when what happened is that it blinked.
|
||||||
|
const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []);
|
||||||
|
assert.ok(shown.length > 0, "no progress was shown while the history loaded");
|
||||||
|
for (const sample of shown) {
|
||||||
|
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
|
||||||
|
assert.equal(sample.coversCentre, false, "the progress sits over the page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// And all of it arrives, on the first load.
|
||||||
|
await replay.page.waitForTimeout(2000);
|
||||||
|
const restored = await replay.page.evaluate(() => ({
|
||||||
|
ais: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
|
progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
|
||||||
|
}));
|
||||||
|
assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
|
||||||
|
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
|
||||||
|
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
|
||||||
|
|
||||||
|
// Opening the map for the first time has to show the stored history too.
|
||||||
|
// The map module is lazy, so at the moment the history was restored its
|
||||||
|
// aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every
|
||||||
|
// position was dropped. Nothing replayed them when the module finally
|
||||||
|
// arrived, so the map came up empty and only filled in from decodes heard
|
||||||
|
// afterwards -- a station heard once was never plotted at all, and it took a
|
||||||
|
// second reload (module cached, so loaded early enough to beat the history
|
||||||
|
// fetch) before the map showed anything.
|
||||||
|
//
|
||||||
|
// This fixture serves no live decode stream on purpose: with one, fresh
|
||||||
|
// frames arriving after the module loads would paper over the whole thing.
|
||||||
|
const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map);
|
||||||
|
assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing");
|
||||||
|
|
||||||
|
await replay.page.locator('.tab[data-tab="map"]').click();
|
||||||
|
await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await replay.page.waitForTimeout(1500);
|
||||||
|
const plotted = await replay.page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const size = (collection) => (collection instanceof Map
|
||||||
|
? collection.size
|
||||||
|
: Object.keys(collection ?? {}).length);
|
||||||
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
|
});
|
||||||
|
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
||||||
|
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
||||||
|
|
||||||
|
assert.deepEqual(replay.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await replay.browser.close();
|
||||||
|
await historyFixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The APRS list: one line per frame, with the information field read for the
|
||||||
|
// operator rather than shown as it arrives on the air.
|
||||||
|
const APRS_FRAMES = [
|
||||||
|
{ packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" },
|
||||||
|
{ packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" },
|
||||||
|
{ packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" },
|
||||||
|
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
|
||||||
|
].map((frame, index) => ({
|
||||||
|
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
|
||||||
|
// Only position reports carry a symbol here, which is the case the columns
|
||||||
|
// have to survive: a frame without one used to close the gap and shift
|
||||||
|
// everything after it left.
|
||||||
|
...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}),
|
||||||
|
rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
|
||||||
|
...frame,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The AIS list is the same shape: a line per message, saying where the vessel
|
||||||
|
// is and what it is doing, with the identifiers behind it.
|
||||||
|
const AIS_MESSAGES = [
|
||||||
|
{ message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A",
|
||||||
|
lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 },
|
||||||
|
{ message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B",
|
||||||
|
callsign: "PBTX", destination: "GDANSK" },
|
||||||
|
].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message }));
|
||||||
|
|
||||||
|
const aprsFixture = await startWebFixture({
|
||||||
|
spectrum: true,
|
||||||
|
mode: "AIS",
|
||||||
|
history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES },
|
||||||
|
});
|
||||||
|
const aprs = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await aprs.page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
await aprs.page.waitForTimeout(2000);
|
||||||
|
await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click();
|
||||||
|
await aprs.page.waitForTimeout(400);
|
||||||
|
|
||||||
|
const rows = await aprs.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
|
||||||
|
tag: row.tagName,
|
||||||
|
height: Math.round(row.getBoundingClientRect().height),
|
||||||
|
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
|
||||||
|
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
|
||||||
|
})));
|
||||||
|
// Newest first, so find each by the type it carries rather than by position.
|
||||||
|
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
|
||||||
|
assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`);
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.equal(row.tag, "DETAILS", "a frame is not expandable in place");
|
||||||
|
assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`);
|
||||||
|
}
|
||||||
|
// Each payload read rather than echoed: 25 °C from t077, the addressee from
|
||||||
|
// a message, the sequence from telemetry, the fix from a position report.
|
||||||
|
assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`);
|
||||||
|
assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`);
|
||||||
|
assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`);
|
||||||
|
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
|
||||||
|
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
|
||||||
|
|
||||||
|
// Frames with and without a symbol line up: the slot is held open either way.
|
||||||
|
const columns = await aprs.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
|
||||||
|
call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x),
|
||||||
|
summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x),
|
||||||
|
symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"),
|
||||||
|
})));
|
||||||
|
assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol),
|
||||||
|
"the sample has to mix frames with and without a symbol to test this");
|
||||||
|
assert.equal(new Set(columns.map((column) => column.call)).size, 1,
|
||||||
|
`callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`);
|
||||||
|
assert.equal(new Set(columns.map((column) => column.summary)).size, 1,
|
||||||
|
`summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`);
|
||||||
|
|
||||||
|
// The frame as it arrived is still there, one click away.
|
||||||
|
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
|
||||||
|
.locator(".decode-line").first().click();
|
||||||
|
await aprs.page.waitForTimeout(200);
|
||||||
|
const expanded = await aprs.page.evaluate(() => {
|
||||||
|
const row = document.querySelector("#aprs-packets .aprs-packet[open]");
|
||||||
|
return {
|
||||||
|
open: row.hasAttribute("open"),
|
||||||
|
raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "",
|
||||||
|
meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(expanded.open, true, "the frame did not open");
|
||||||
|
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
|
||||||
|
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
|
||||||
|
|
||||||
|
// AIS, on the same row.
|
||||||
|
await aprs.page.locator('.sub-tab[data-subtab="ais"]').click();
|
||||||
|
await aprs.page.waitForTimeout(300);
|
||||||
|
const aisRows = await aprs.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({
|
||||||
|
tag: row.tagName,
|
||||||
|
height: Math.round(row.getBoundingClientRect().height),
|
||||||
|
name: row.querySelector(".ais-call")?.textContent?.trim() ?? "",
|
||||||
|
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
|
||||||
|
})));
|
||||||
|
assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`);
|
||||||
|
for (const row of aisRows) {
|
||||||
|
assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place");
|
||||||
|
assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`);
|
||||||
|
}
|
||||||
|
const positionRow = aisRows.find((row) => row.name === "NEDERLAND");
|
||||||
|
const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT");
|
||||||
|
assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`);
|
||||||
|
assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`);
|
||||||
|
// A static report carries no fix, so it says where the vessel is going.
|
||||||
|
assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`);
|
||||||
|
|
||||||
|
await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click();
|
||||||
|
await aprs.page.waitForTimeout(200);
|
||||||
|
const aisExpanded = await aprs.page.evaluate(() =>
|
||||||
|
document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? "");
|
||||||
|
assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`);
|
||||||
|
assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`);
|
||||||
|
|
||||||
|
assert.deepEqual(aprs.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await aprs.browser.close();
|
||||||
|
await aprsFixture.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// What a phone gets. A page that scrolls sideways is a page with something on
|
||||||
|
// it nobody will find: the transmit controls spent this whole period laid out
|
||||||
|
// at x=400 on a 390 px screen, off the side of a tray 354 px wide, reachable
|
||||||
|
// only by a horizontal scroll with nothing to say it was there.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, getComputedStyle */
|
||||||
|
|
||||||
|
const PHONES = [430, 390, 360];
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({ spectrum: true, tx: true });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const width of PHONES) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
|
||||||
|
const layout = await page.evaluate((viewport) => {
|
||||||
|
const box = (element) => element.getBoundingClientRect();
|
||||||
|
// Anything laid out past the right edge of the screen.
|
||||||
|
const past = [];
|
||||||
|
const walk = (element) => {
|
||||||
|
const rect = box(element);
|
||||||
|
if (rect.width > 0 && rect.right > viewport + 1) {
|
||||||
|
past.push(`${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`);
|
||||||
|
}
|
||||||
|
for (const child of element.children) walk(child);
|
||||||
|
};
|
||||||
|
walk(document.getElementById("content"));
|
||||||
|
const tx = document.getElementById("tx-power-col");
|
||||||
|
return {
|
||||||
|
documentScroll: document.documentElement.scrollWidth,
|
||||||
|
past: past.slice(0, 6),
|
||||||
|
txRight: tx ? Math.round(box(tx).right) : null,
|
||||||
|
txVisible: tx ? getComputedStyle(tx).display !== "none" : false,
|
||||||
|
rigSelect: Math.round(box(document.getElementById("header-rig-switch-select")).width),
|
||||||
|
};
|
||||||
|
}, width);
|
||||||
|
|
||||||
|
assert.equal(layout.documentScroll, width,
|
||||||
|
`the page scrolls sideways at ${width}px (${layout.documentScroll}px wide)`);
|
||||||
|
assert.deepEqual(layout.past, [],
|
||||||
|
`laid out past the screen at ${width}px: ${layout.past.join(", ")}`);
|
||||||
|
if (layout.txVisible) {
|
||||||
|
assert.ok(layout.txRight <= width + 1,
|
||||||
|
`the transmit controls end at ${layout.txRight}px on a ${width}px screen`);
|
||||||
|
}
|
||||||
|
// The rig's name was taking 139px of a 338px bar and pushing everything
|
||||||
|
// else into the overflow menu.
|
||||||
|
assert.ok(layout.rigSelect <= 112,
|
||||||
|
`the rig picker is ${layout.rigSelect}px wide at ${width}px`);
|
||||||
|
|
||||||
|
// The bottom nav keeps its labels — that is what makes it navigation
|
||||||
|
// rather than five glyphs — and a label used to overflow its tab and run
|
||||||
|
// into the next one: "Bookmarks igital mode".
|
||||||
|
//
|
||||||
|
// What is asserted is that a label stays inside its own tab and, if it is
|
||||||
|
// too long for it, ends in an ellipsis. Not that it fits: how wide a
|
||||||
|
// platform draws a word varies by more than ten per cent, so "it fits"
|
||||||
|
// passes on the machine it was written on and fails on the next one —
|
||||||
|
// which is what a fixed font size did here.
|
||||||
|
const nav = await page.evaluate(() => {
|
||||||
|
const bar = document.querySelector(".tab-bar-nav");
|
||||||
|
return [...bar.querySelectorAll(".tab")]
|
||||||
|
.filter((tab) => tab.getBoundingClientRect().width > 0)
|
||||||
|
.map((tab) => {
|
||||||
|
const label = [...tab.querySelectorAll(".tab-label, .tab-label-short")]
|
||||||
|
.find((span) => getComputedStyle(span).display !== "none");
|
||||||
|
const style = label ? getComputedStyle(label) : null;
|
||||||
|
const labelBox = label?.getBoundingClientRect();
|
||||||
|
const tabBox = tab.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
text: (label?.textContent ?? "").trim(),
|
||||||
|
escapes: labelBox
|
||||||
|
? labelBox.left < tabBox.left - 1 || labelBox.right > tabBox.right + 1
|
||||||
|
: false,
|
||||||
|
truncates: style?.textOverflow === "ellipsis" && style?.overflow !== "visible",
|
||||||
|
empty: !labelBox || labelBox.width < 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.ok(nav.length >= 4, `the bottom nav has ${nav.length} destinations at ${width}px`);
|
||||||
|
const escaped = nav.filter((tab) => tab.escapes).map((tab) => tab.text);
|
||||||
|
assert.deepEqual(escaped, [],
|
||||||
|
`labels overflowing their tab at ${width}px: ${escaped.join(", ")}`);
|
||||||
|
const untruncatable = nav.filter((tab) => !tab.truncates).map((tab) => tab.text);
|
||||||
|
assert.deepEqual(untruncatable, [],
|
||||||
|
`labels that would be cut rather than ellipsised at ${width}px: ${untruncatable.join(", ")}`);
|
||||||
|
const blank = nav.filter((tab) => tab.empty).map((tab, index) => tab.text || `#${index}`);
|
||||||
|
assert.deepEqual(blank, [], `destinations with no label at ${width}px: ${blank.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tab whose visible label is shortened for the nav still has to say what
|
||||||
|
// it is to anything that reads the page rather than looks at it.
|
||||||
|
const named = await page.evaluate(() => {
|
||||||
|
const tab = document.querySelector('.tab[data-tab="digital-modes"]');
|
||||||
|
const short = tab.querySelector(".tab-label-short");
|
||||||
|
return {
|
||||||
|
label: tab.getAttribute("aria-label"),
|
||||||
|
shortHidden: short?.getAttribute("aria-hidden"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(named.label, "Digital modes", "the shortened tab lost its full name");
|
||||||
|
assert.equal(named.shortHidden, "true", "the short label is read out as well as shown");
|
||||||
|
|
||||||
|
// Hiding the map's filters leaves the bar, and the bar has to still be
|
||||||
|
// there to bring them back — it carries the only button that does.
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await page.goto(`${fixture.origin}/map`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.locator("#map-overlay-toggle-btn").click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const collapsed = await page.evaluate(() => {
|
||||||
|
const panel = document.querySelector(".map-overlay-panel");
|
||||||
|
const style = getComputedStyle(panel);
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
const button = document.getElementById("map-overlay-toggle-btn").getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
height: Math.round(rect.height),
|
||||||
|
opacity: Number(style.opacity),
|
||||||
|
visibility: style.visibility,
|
||||||
|
display: style.display,
|
||||||
|
buttonWidth: Math.round(button.width),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(collapsed.width > 0 && collapsed.height > 0,
|
||||||
|
`the collapsed bar measures ${collapsed.width}x${collapsed.height}`);
|
||||||
|
assert.equal(collapsed.visibility, "visible", "the collapsed bar is not visible");
|
||||||
|
assert.notEqual(collapsed.display, "none", "the collapsed bar is display:none");
|
||||||
|
assert.equal(collapsed.opacity, 1, `the collapsed bar is at opacity ${collapsed.opacity}`);
|
||||||
|
assert.ok(collapsed.buttonWidth > 0, "Show Filters has no size to click");
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
+54
-1
@@ -17,7 +17,8 @@ async function loadRoutes(window) {
|
|||||||
write: false,
|
write: false,
|
||||||
});
|
});
|
||||||
const module = { exports: {} };
|
const module = { exports: {} };
|
||||||
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
|
// The sandbox is bare; the browser globals the module actually uses go in.
|
||||||
|
vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window, URLSearchParams });
|
||||||
return module.exports;
|
return module.exports;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,3 +39,55 @@ test("all top-level tabs have stable round-trip routes", async () => {
|
|||||||
assert.equal(calls[0][0], "push");
|
assert.equal(calls[0][0], "push");
|
||||||
assert.equal(calls[0][3], "/statistics?rig=one#panel");
|
assert.equal(calls[0][3], "/statistics?rig=one#panel");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A tune link is what one operator sends another: it has to survive being
|
||||||
|
// typed by hand, and it has to come back out as the frequency that went in.
|
||||||
|
test("tune links parse the frequencies people write", async () => {
|
||||||
|
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||||
|
for (const [text, hz] of [
|
||||||
|
["14074000", 14_074_000], ["14074000hz", 14_074_000],
|
||||||
|
["7074k", 7_074_000], ["14.074M", 14_074_000], ["1.2G", 1_200_000_000],
|
||||||
|
[" 145500k ", 145_500_000],
|
||||||
|
]) {
|
||||||
|
assert.equal(routes.parseFrequencyParam(text), hz, `${text} did not read as ${hz} Hz`);
|
||||||
|
}
|
||||||
|
for (const bad of ["", " ", "abc", "-7074", "0", "14,074", "1e6", null, undefined]) {
|
||||||
|
assert.equal(routes.parseFrequencyParam(bad), null, `${bad} was read as a frequency`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tune links carry rig, frequency, mode and bandwidth", async () => {
|
||||||
|
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||||
|
assert.deepEqual({ ...routes.parseTuneLink("?rig=sdr&f=14.074M&mode=usb&bw=3k") }, {
|
||||||
|
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||||
|
});
|
||||||
|
// A link with nothing to say must not tune anything.
|
||||||
|
assert.deepEqual({ ...routes.parseTuneLink("?tab=map") }, {
|
||||||
|
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||||
|
});
|
||||||
|
// Junk in a parameter is not a frequency, and must not become one.
|
||||||
|
assert.deepEqual({ ...routes.parseTuneLink("?f=&mode=NOTAMODE&bw=wide") }, {
|
||||||
|
rig: null, freqHz: null, mode: null, bandwidthHz: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writing a tune link canonicalises it and leaves other parameters alone", async () => {
|
||||||
|
const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
|
||||||
|
const search = routes.tuneLinkSearch("?theme=dark&f=7074k", {
|
||||||
|
rig: "sdr", freqHz: 14_074_000.4, mode: "usb", bandwidthHz: 3000,
|
||||||
|
});
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
assert.equal(params.get("theme"), "dark", "an unrelated parameter was dropped");
|
||||||
|
assert.equal(params.get("f"), "14074000", "the frequency was not written as whole Hz");
|
||||||
|
assert.equal(params.get("mode"), "USB");
|
||||||
|
assert.equal(params.get("bw"), "3000");
|
||||||
|
assert.equal(params.get("rig"), "sdr");
|
||||||
|
// Round trip: what was written reads back as what went in.
|
||||||
|
assert.deepEqual({ ...routes.parseTuneLink(search) }, {
|
||||||
|
rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
|
||||||
|
});
|
||||||
|
// Nothing known yet means no stale tune parameters left behind.
|
||||||
|
const cleared = routes.tuneLinkSearch(search, { rig: null, freqHz: null, mode: null, bandwidthHz: null });
|
||||||
|
assert.equal(cleared, "?theme=dark");
|
||||||
|
assert.equal(routes.tuneLinkSearch("", { rig: null, freqHz: null, mode: null, bandwidthHz: null }), "");
|
||||||
|
});
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
|
|||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
/* global document, getComputedStyle */
|
/* global document, getComputedStyle, window */
|
||||||
|
|
||||||
const BOOKMARKS = [
|
const BOOKMARKS = [
|
||||||
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
|
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
|
||||||
@@ -196,6 +196,62 @@ try {
|
|||||||
await fixture.close();
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The strip of controls under the plot. Its two clusters — the receiver's
|
||||||
|
// bandwidth and the display's levels — used to size themselves independently:
|
||||||
|
// four control heights on one line, and between about 1100 and 1400 px the
|
||||||
|
// left cluster wrapped to two lines while the right one did not, leaving the
|
||||||
|
// two at heights that matched neither each other nor anything else.
|
||||||
|
const stripFixture = await startWebFixture({ spectrum: true });
|
||||||
|
const strip = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
for (const width of [1600, 1200, 900]) {
|
||||||
|
await strip.page.setViewportSize({ width, height: 950 });
|
||||||
|
await strip.page.goto(stripFixture.origin, { waitUntil: "domcontentloaded" });
|
||||||
|
await strip.page.locator("#spectrum-panel").waitFor({ state: "visible" });
|
||||||
|
await strip.page.waitForTimeout(1600);
|
||||||
|
|
||||||
|
const measured = await strip.page.evaluate(() => {
|
||||||
|
const controls = document.getElementById("spectrum-controls");
|
||||||
|
const box = (element) => element.getBoundingClientRect();
|
||||||
|
const clusters = [...controls.children]
|
||||||
|
.filter((child) => child.id)
|
||||||
|
.map((child) => ({ id: child.id, top: Math.round(box(child).top) }));
|
||||||
|
const parts = [...controls.querySelectorAll(".spectrum-field, .spectrum-btn")];
|
||||||
|
return {
|
||||||
|
heights: [...new Set(parts.map((part) => Math.round(box(part).height)))],
|
||||||
|
count: parts.length,
|
||||||
|
clusters,
|
||||||
|
// Rows are lines of the strip: clusters sharing a top are on one line.
|
||||||
|
lines: new Set(clusters.map((cluster) => cluster.top)).size,
|
||||||
|
overflows: controls.scrollWidth > controls.clientWidth + 1,
|
||||||
|
insidePanel: box(controls).right
|
||||||
|
<= box(document.getElementById("spectrum-panel")).right + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Five fields and four buttons: bandwidth, Set, Auto BW, Sweet-spot, peak
|
||||||
|
// hold, floor, range, Auto, contrast.
|
||||||
|
assert.equal(measured.count, 9, `the strip has ${measured.count} controls at ${width}px`);
|
||||||
|
assert.deepEqual(measured.heights.length, 1,
|
||||||
|
`controls are ${measured.heights.join(", ")}px tall at ${width}px`);
|
||||||
|
assert.equal(measured.overflows, false, `the strip overflows at ${width}px`);
|
||||||
|
assert.equal(measured.insidePanel, true, `the strip runs past the plot at ${width}px`);
|
||||||
|
|
||||||
|
// Either both clusters share a line, or each has one to itself. What must
|
||||||
|
// never happen is one cluster floating against the middle of the other.
|
||||||
|
const tops = measured.clusters.map((cluster) => cluster.top);
|
||||||
|
assert.ok(
|
||||||
|
measured.lines === 1 || measured.lines === measured.clusters.length,
|
||||||
|
`clusters sit at ${tops.join(", ")} at ${width}px`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(strip.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await strip.browser.close();
|
||||||
|
await stripFixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
// The band plan is fetched once at startup, which can land before the session
|
// The band plan is fetched once at startup, which can land before the session
|
||||||
// exists. It used to fail silently and never retry, so the allocations only
|
// exists. It used to fail silently and never retry, so the allocations only
|
||||||
// turned up if the operator reloaded the page by hand.
|
// turned up if the operator reloaded the page by hand.
|
||||||
@@ -222,3 +278,159 @@ try {
|
|||||||
await retry.browser.close();
|
await retry.browser.close();
|
||||||
await retryFixture.close();
|
await retryFixture.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The map's filter panel is a bar across the top of the map, not a window
|
||||||
|
// sitting on it: it has to stay one or two rows tall, span most of the width,
|
||||||
|
// and keep clear of what shares the map's corners — Leaflet's zoom buttons and
|
||||||
|
// the band legend. A panel that grew a column would cover the map it filters.
|
||||||
|
// Fullscreen and the filter toggle ride at the bar's right-hand end, so only
|
||||||
|
// the filters collapse: the bar itself has to survive Hide Filters, or there
|
||||||
|
// is nothing left to click to bring them back.
|
||||||
|
const mapFixture = await startWebFixture({ spectrum: true });
|
||||||
|
const mapView = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
for (const width of [1600, 1200]) {
|
||||||
|
await mapView.page.setViewportSize({ width, height: 950 });
|
||||||
|
await mapView.page.goto(`${mapFixture.origin}/map`, { waitUntil: "domcontentloaded" });
|
||||||
|
await mapView.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
|
||||||
|
await mapView.page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
const bar = await mapView.page.evaluate(() => {
|
||||||
|
const box = (element) => (element ? element.getBoundingClientRect() : null);
|
||||||
|
const panel = document.querySelector(".map-overlay-panel");
|
||||||
|
const stage = box(document.getElementById("map-stage"));
|
||||||
|
const zoom = box(document.querySelector("#aprs-map .leaflet-control-zoom"));
|
||||||
|
const legend = box(document.getElementById("map-band-legend"));
|
||||||
|
const panelBox = box(panel);
|
||||||
|
const hits = (a, b) => !!a && !!b
|
||||||
|
&& a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||||
|
return {
|
||||||
|
widthPct: Math.round((panelBox.width / stage.width) * 100),
|
||||||
|
height: Math.round(panelBox.height),
|
||||||
|
outsideStage: panelBox.right > stage.right + 1 || panelBox.bottom > stage.bottom + 1,
|
||||||
|
hitsZoom: hits(panelBox, zoom),
|
||||||
|
hitsLegend: hits(panelBox, legend),
|
||||||
|
// Both controls belong to the bar now, not to a floating corner block.
|
||||||
|
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
|
||||||
|
.map((button) => button.id).join(","),
|
||||||
|
// The rule dividing them from the filters is drawn on this block's
|
||||||
|
// edge, so the block has to run the height of the filters beside it —
|
||||||
|
// centred, it left the rule floating as a stub against a two-row bar.
|
||||||
|
actionsShort: Math.round(box(panel.querySelector(".map-overlay-filters")).height
|
||||||
|
- box(panel.querySelector(".map-overlay-actions")).height),
|
||||||
|
// Every label sits in a gutter of one width, so whichever group leads
|
||||||
|
// a row leads it from the same place: with the labels at their natural
|
||||||
|
// widths, a row starting "Search" began 10px off one starting "Filter".
|
||||||
|
rowStarts: (() => {
|
||||||
|
const leaders = new Map();
|
||||||
|
for (const group of panel.querySelectorAll(".map-overlay-filters .map-locator-filter-group")) {
|
||||||
|
const rect = box(group);
|
||||||
|
// Groups of a row differ in height, so bucket them by their middle.
|
||||||
|
const row = Math.round((rect.top + rect.height / 2) / 20);
|
||||||
|
if (!leaders.has(row) || rect.left < box(leaders.get(row)).left) leaders.set(row, group);
|
||||||
|
}
|
||||||
|
return [...leaders.values()].map((group) => Math.round(box(group.children[1]).left));
|
||||||
|
})(),
|
||||||
|
clipped: panel.scrollWidth > panel.clientWidth + 1 || panel.scrollHeight > panel.clientHeight + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(bar.widthPct >= 70, `the filter bar covers ${bar.widthPct}% of the map at ${width}px`);
|
||||||
|
assert.ok(bar.height <= 140, `the filter bar is ${bar.height}px tall at ${width}px, not a bar`);
|
||||||
|
assert.equal(bar.outsideStage, false, `the filter bar runs off the map at ${width}px`);
|
||||||
|
assert.equal(bar.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`);
|
||||||
|
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
|
||||||
|
`the bar carries "${bar.actionsInBar}" at ${width}px`);
|
||||||
|
assert.ok(bar.actionsShort <= 1,
|
||||||
|
`the buttons' divider falls ${bar.actionsShort}px short of the bar at ${width}px`);
|
||||||
|
assert.equal(new Set(bar.rowStarts).size, 1,
|
||||||
|
`the bar's rows start at ${bar.rowStarts.join(", ")}px at ${width}px`);
|
||||||
|
assert.equal(bar.hitsLegend, false, `the filter bar covers the band legend at ${width}px`);
|
||||||
|
assert.equal(bar.clipped, false, `the filter bar is clipping its own controls at ${width}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band chips only exist once something has been heard on a band. The bar
|
||||||
|
// used to explain "all bands visible by default" in a line of prose wedged
|
||||||
|
// between the chips and the next group, which is neither what a toolbar is
|
||||||
|
// for nor a width it can spare: an "All" chip says it and undoes a
|
||||||
|
// selection. Nothing is dimmed while nothing is filtered out, either — every
|
||||||
|
// chip used to come up greyed at the very moment all of them were showing.
|
||||||
|
await mapView.page.evaluate(() => {
|
||||||
|
const ts = Date.now();
|
||||||
|
for (const [grid, hz] of [["JO94", 14_074_000], ["JN48", 7_074_000], ["FN42", 21_074_000]]) {
|
||||||
|
window.trxPluginRuntime.dispatch("ft8",
|
||||||
|
{ message: `CQ TEST ${grid}`, grid, freq_hz: hz, snr_db: -8, ts_ms: ts, rig_id: "rig-a" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mapView.page.waitForTimeout(1500);
|
||||||
|
const chipRow = () => mapView.page.evaluate(() => {
|
||||||
|
const row = document.getElementById("map-locator-choice-filter");
|
||||||
|
const chips = [...row.querySelectorAll(".map-locator-chip")];
|
||||||
|
const all = row.querySelector(".map-locator-chip-all");
|
||||||
|
return {
|
||||||
|
bands: chips.filter((chip) => chip !== all).map((chip) => chip.textContent.trim()),
|
||||||
|
prose: row.querySelector(".map-locator-empty")?.textContent ?? null,
|
||||||
|
allActive: all?.classList.contains("is-active") ?? null,
|
||||||
|
dimmed: chips.filter((chip) => chip.classList.contains("is-inactive"))
|
||||||
|
.map((chip) => chip.textContent.trim()),
|
||||||
|
selected: chips.filter((chip) => chip.getAttribute("aria-pressed") === "true"
|
||||||
|
&& chip !== all).map((chip) => chip.textContent.trim()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const unfiltered = await chipRow();
|
||||||
|
assert.ok(unfiltered.bands.includes("20m") && unfiltered.bands.includes("40m"),
|
||||||
|
`the chip row is showing ${unfiltered.bands.join(",")}`);
|
||||||
|
assert.equal(unfiltered.prose, null, `the bar is explaining itself in prose: "${unfiltered.prose}"`);
|
||||||
|
assert.equal(unfiltered.allActive, true, "All is not lit while every band is on the map");
|
||||||
|
assert.deepEqual(unfiltered.dimmed, [], `${unfiltered.dimmed.join(",")} came up dimmed with no filter set`);
|
||||||
|
|
||||||
|
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="20m"]').click();
|
||||||
|
await mapView.page.waitForTimeout(300);
|
||||||
|
const filtered = await chipRow();
|
||||||
|
assert.deepEqual(filtered.selected, ["20m"], `picking 20m selected ${filtered.selected.join(",")}`);
|
||||||
|
assert.equal(filtered.allActive, false, "All stayed lit with a band picked out");
|
||||||
|
assert.ok(filtered.dimmed.includes("40m"), "the bands now filtered out are not shown as such");
|
||||||
|
|
||||||
|
// And back: All is how a selection is undone without hunting for the chips
|
||||||
|
// that are in it.
|
||||||
|
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip-all').click();
|
||||||
|
await mapView.page.waitForTimeout(300);
|
||||||
|
const cleared = await chipRow();
|
||||||
|
assert.equal(cleared.allActive, true, "All did not clear the band selection");
|
||||||
|
assert.deepEqual(cleared.selected, [], `${cleared.selected.join(",")} survived All`);
|
||||||
|
assert.deepEqual(cleared.dimmed, [], `${cleared.dimmed.join(",")} stayed dimmed after All`);
|
||||||
|
|
||||||
|
// Hiding gives the map back, but leaves the bar itself — collapsed to its
|
||||||
|
// two controls at the right-hand edge — so the filters can be brought back.
|
||||||
|
await mapView.page.locator("#map-overlay-toggle-btn").click();
|
||||||
|
await mapView.page.waitForTimeout(400);
|
||||||
|
const toggled = await mapView.page.evaluate(() => {
|
||||||
|
const panel = document.querySelector(".map-overlay-panel");
|
||||||
|
const stage = document.getElementById("map-stage").getBoundingClientRect();
|
||||||
|
const panelBox = panel.getBoundingClientRect();
|
||||||
|
const visible = (id) => document.getElementById(id).getBoundingClientRect().width > 0;
|
||||||
|
return {
|
||||||
|
filtersHidden: panel.querySelector(".map-overlay-filters").classList.contains("is-hidden"),
|
||||||
|
widthPct: Math.round((panelBox.width / stage.width) * 100),
|
||||||
|
rightGap: Math.round(stage.right - panelBox.right),
|
||||||
|
label: document.getElementById("map-overlay-toggle-btn").textContent.trim(),
|
||||||
|
togglesVisible: visible("map-fullscreen-btn") && visible("map-overlay-toggle-btn"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(toggled.filtersHidden, true, "the filters stayed up after Hide Filters");
|
||||||
|
assert.equal(toggled.togglesVisible, true, "Hide Filters took its own button down with it");
|
||||||
|
assert.ok(toggled.widthPct < 30, `the collapsed bar still covers ${toggled.widthPct}% of the map`);
|
||||||
|
assert.ok(toggled.rightGap < 30, `the collapsed bar sits ${toggled.rightGap}px from the map's edge`);
|
||||||
|
assert.equal(toggled.label, "Show Filters", `the toggle still reads "${toggled.label}"`);
|
||||||
|
|
||||||
|
await mapView.page.locator("#map-overlay-toggle-btn").click();
|
||||||
|
await mapView.page.waitForTimeout(400);
|
||||||
|
const restored = await mapView.page.evaluate(() =>
|
||||||
|
!document.querySelector(".map-overlay-filters").classList.contains("is-hidden"));
|
||||||
|
assert.equal(restored, true, "Show Filters did not bring the filters back");
|
||||||
|
|
||||||
|
assert.deepEqual(mapView.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await mapView.browser.close();
|
||||||
|
await mapFixture.close();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The SSTV panel: a picture arriving row by row, and what is kept once it has.
|
||||||
|
// Watching the image build up is the point of the mode, so rows have to reach
|
||||||
|
// the canvas as they arrive rather than at the end of a two-minute frame.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
import { createHost } from "./host-fixture.mjs";
|
||||||
|
|
||||||
|
/** A DOM stub with only what the plugin reaches for. */
|
||||||
|
function makeElement(id) {
|
||||||
|
const listeners = new Map();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
innerHTML: "",
|
||||||
|
value: "",
|
||||||
|
style: {},
|
||||||
|
attributes: {},
|
||||||
|
classList: {
|
||||||
|
classes: new Set(),
|
||||||
|
add(name) { this.classes.add(name); },
|
||||||
|
remove(name) { this.classes.delete(name); },
|
||||||
|
toggle(name, on) { if (on) this.classes.add(name); else this.classes.delete(name); },
|
||||||
|
contains(name) { return this.classes.has(name); },
|
||||||
|
},
|
||||||
|
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||||
|
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||||
|
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||||
|
click() { listeners.get("click")?.(); },
|
||||||
|
fire(type, event) { listeners.get(type)?.(event); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCanvas(id) {
|
||||||
|
const element = makeElement(id);
|
||||||
|
element.width = 0;
|
||||||
|
element.height = 0;
|
||||||
|
const painted = [];
|
||||||
|
const fills = [];
|
||||||
|
element.painted = painted;
|
||||||
|
element.fills = fills;
|
||||||
|
element.getContext = () => ({
|
||||||
|
fillStyle: "",
|
||||||
|
fillRect: (...args) => { fills.push(args); },
|
||||||
|
createImageData: (width, height) => ({
|
||||||
|
width, height, data: new Uint8ClampedArray(width * height * 4),
|
||||||
|
}),
|
||||||
|
putImageData: (image, x, y) => { painted.push({ x, y, data: image.data }); },
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPanel() {
|
||||||
|
const elements = new Map();
|
||||||
|
const element = (id) => {
|
||||||
|
if (!elements.has(id)) {
|
||||||
|
elements.set(id, id.endsWith("canvas") ? makeCanvas(id) : makeElement(id));
|
||||||
|
}
|
||||||
|
return elements.get(id);
|
||||||
|
};
|
||||||
|
// Touch every id the panel defines, so the plugin caches real stubs.
|
||||||
|
for (const id of [
|
||||||
|
"sstv-status", "sstv-live-view", "sstv-history-view", "sstv-live-container",
|
||||||
|
"sstv-live-info", "sstv-live-canvas", "sstv-live-latest", "sstv-history-list",
|
||||||
|
"sstv-history-count", "sstv-filter", "sstv-sort", "sstv-decode-toggle-btn",
|
||||||
|
"sstv-clear-btn", "sstv-view-live", "sstv-view-history",
|
||||||
|
]) element(id);
|
||||||
|
|
||||||
|
const window = { ...createHost() };
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: { getElementById: (id) => elements.get(id) ?? null },
|
||||||
|
atob: (data) => Buffer.from(data, "base64").toString("binary"),
|
||||||
|
Date, Number, String, Math, Set, Uint8Array, Uint8ClampedArray, JSON, console,
|
||||||
|
});
|
||||||
|
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||||||
|
const source = await bundleEntry(new URL("../src/plugins/sstv.ts", import.meta.url));
|
||||||
|
new vm.Script(runtime).runInContext(context);
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
return { window, runtime: window.trxPluginRuntime, element };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of RGB triples, base64 as the server sends it. */
|
||||||
|
function rowData(width, [r, g, b]) {
|
||||||
|
const bytes = new Uint8Array(width * 3);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
bytes[x * 3] = r;
|
||||||
|
bytes[x * 3 + 1] = g;
|
||||||
|
bytes[x * 3 + 2] = b;
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a picture arriving paints its rows as they come", async () => {
|
||||||
|
const { runtime, element } = await loadPanel();
|
||||||
|
|
||||||
|
// The header names the mode and the geometry; the canvas takes both.
|
||||||
|
runtime.dispatch("sstv_progress", {
|
||||||
|
state: "Receiving Martin M1", mode: "Martin M1", width: 320, height: 256, line: 0,
|
||||||
|
});
|
||||||
|
const canvas = element("sstv-live-canvas");
|
||||||
|
assert.equal(canvas.width, 320, "the canvas did not take the mode's width");
|
||||||
|
assert.equal(canvas.height, 256, "the canvas did not take the mode's height");
|
||||||
|
assert.equal(element("sstv-live-container").style.display, "",
|
||||||
|
"the live view stayed hidden while a picture was arriving");
|
||||||
|
assert.match(element("sstv-status").textContent, /Martin M1/);
|
||||||
|
|
||||||
|
// Rows land at the line number they carry, not in arrival order: a decoder
|
||||||
|
// that painted them in sequence would shear a picture with a dropped line.
|
||||||
|
runtime.dispatch("sstv_progress", { line: 4, line_data: rowData(320, [255, 0, 0]) });
|
||||||
|
runtime.dispatch("sstv_progress", { line: 2, line_data: rowData(320, [0, 0, 255]) });
|
||||||
|
assert.deepEqual(canvas.painted.map((p) => p.y), [4, 2],
|
||||||
|
`rows painted at ${canvas.painted.map((p) => p.y).join(",")}`);
|
||||||
|
assert.deepEqual([...canvas.painted[0].data.slice(0, 4)], [255, 0, 0, 255], "row 4 is not red");
|
||||||
|
assert.deepEqual([...canvas.painted[1].data.slice(0, 4)], [0, 0, 255, 255], "row 2 is not blue");
|
||||||
|
assert.match(element("sstv-live-info").textContent, /Martin M1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a received picture is kept, shown, and linked by file name alone", async () => {
|
||||||
|
const { runtime, element } = await loadPanel();
|
||||||
|
|
||||||
|
// Recent, not a fixed date: the panel drops anything older than the history
|
||||||
|
// retention window, so a picture stamped with the day the test was written
|
||||||
|
// passes until that day is a day ago.
|
||||||
|
runtime.dispatch("sstv", {
|
||||||
|
ts_ms: Date.now() - 60_000,
|
||||||
|
vis: 44, mode: "Martin M1", width: 320, height: 256, lines: 256, complete: true,
|
||||||
|
path: "/home/op/.cache/trx-rs/sstv/SSTV_20260805T120000Z_14230000_Martin-M1.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const latest = element("sstv-live-latest").innerHTML;
|
||||||
|
assert.match(latest, /Martin M1/);
|
||||||
|
assert.match(latest, /complete/);
|
||||||
|
// The server serves pictures by file name; the path it stored is its own.
|
||||||
|
assert.match(latest, /\/sstv-images\/SSTV_20260805T120000Z_14230000_Martin-M1\.png/);
|
||||||
|
assert.doesNotMatch(latest, /home\/op/, "the server's filesystem path reached the page");
|
||||||
|
|
||||||
|
element("sstv-view-history").click();
|
||||||
|
const history = element("sstv-history-list").innerHTML;
|
||||||
|
assert.match(history, /Martin M1/);
|
||||||
|
assert.match(history, /320×256/);
|
||||||
|
assert.match(element("sstv-history-count").textContent, /1 picture/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a picture cut short is kept, and says so", async () => {
|
||||||
|
const { runtime, element } = await loadPanel();
|
||||||
|
|
||||||
|
runtime.dispatch("sstv", {
|
||||||
|
ts_ms: Date.now(), vis: 60, mode: "Scottie S1", width: 320, height: 256,
|
||||||
|
lines: 91, complete: false, path: "/cache/SSTV_x_Scottie-S1.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(element("sstv-status").textContent, /Partial/);
|
||||||
|
element("sstv-view-history").click();
|
||||||
|
assert.match(element("sstv-history-list").innerHTML, /91 \(partial\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing empties the panel", async () => {
|
||||||
|
const { runtime, element } = await loadPanel();
|
||||||
|
runtime.dispatch("sstv", { ts_ms: Date.now(), mode: "PD120", lines: 496, complete: true });
|
||||||
|
assert.match(element("sstv-live-latest").innerHTML, /PD120/);
|
||||||
|
|
||||||
|
runtime.reset("sstv");
|
||||||
|
assert.equal(element("sstv-live-latest").innerHTML, "");
|
||||||
|
assert.equal(element("sstv-status").textContent, "Idle");
|
||||||
|
assert.equal(element("sstv-live-container").style.display, "none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the toggle button follows the rig state", async () => {
|
||||||
|
const { window, element } = await loadPanel();
|
||||||
|
const button = element("sstv-decode-toggle-btn");
|
||||||
|
|
||||||
|
window.syncSstvToggle(true);
|
||||||
|
assert.equal(button.textContent, "Disable SSTV");
|
||||||
|
assert.equal(button.getAttribute("aria-pressed"), "true");
|
||||||
|
|
||||||
|
window.syncSstvToggle(false);
|
||||||
|
assert.equal(button.textContent, "Enable SSTV");
|
||||||
|
assert.equal(button.getAttribute("aria-pressed"), "false");
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// A link is how one operator tells another where to listen. Opening one has to
|
||||||
|
// put the radio there, and the address bar has to keep up with the dial after
|
||||||
|
// that — a link that goes stale the moment someone tunes is worse than none,
|
||||||
|
// because it looks like it works.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, window */
|
||||||
|
|
||||||
|
const LINK_HZ = 14_074_000;
|
||||||
|
const LINK_BW = 2_800;
|
||||||
|
const TUNED_HZ = 7_040_000;
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({ spectrum: true });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
const dial = () => page.evaluate(() => ({
|
||||||
|
freqHz: window.lastFreqHz,
|
||||||
|
mode: document.getElementById("mode").value,
|
||||||
|
search: window.location.search,
|
||||||
|
path: window.location.pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
|
||||||
|
// Opening the link tunes the radio: frequency, mode and bandwidth.
|
||||||
|
await page.goto(`${fixture.origin}/?f=14.074M&mode=USB&bw=${LINK_BW}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
const opened = await dial();
|
||||||
|
assert.equal(opened.freqHz, LINK_HZ, `the link left the radio on ${opened.freqHz} Hz`);
|
||||||
|
assert.equal(opened.mode, "USB", `the link left the radio in ${opened.mode}`);
|
||||||
|
const bandwidth = await page.evaluate(() => window.currentBandwidthHz);
|
||||||
|
assert.equal(bandwidth, LINK_BW, `the link left the bandwidth at ${bandwidth} Hz`);
|
||||||
|
|
||||||
|
// Hand-written frequencies are canonicalised in place, so what the operator
|
||||||
|
// copies back out is the same link in the form the app writes.
|
||||||
|
const params = new URLSearchParams(opened.search);
|
||||||
|
assert.equal(params.get("f"), String(LINK_HZ), `the URL kept "${params.get("f")}"`);
|
||||||
|
assert.equal(params.get("mode"), "USB");
|
||||||
|
|
||||||
|
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||||
|
// bar shareable at any moment rather than only at load.
|
||||||
|
await page.locator("#freq").fill("7.040M");
|
||||||
|
await page.locator("#freq").press("Enter");
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const tuned = await dial();
|
||||||
|
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||||
|
assert.equal(new URLSearchParams(tuned.search).get("f"), String(TUNED_HZ),
|
||||||
|
`the URL still reads "${tuned.search}" after tuning`);
|
||||||
|
|
||||||
|
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||||
|
const historyLength = await page.evaluate(() => window.history.length);
|
||||||
|
await page.locator("#freq").fill("7.100M");
|
||||||
|
await page.locator("#freq").press("Enter");
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||||
|
"tuning pushed a history entry instead of replacing one");
|
||||||
|
|
||||||
|
// Moving between tabs keeps the tune parameters: the path changes, the link
|
||||||
|
// does not stop being a link.
|
||||||
|
await page.locator('.tab[data-tab="map"]').click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const onMap = await dial();
|
||||||
|
assert.equal(onMap.path, "/map", `the map tab landed on ${onMap.path}`);
|
||||||
|
assert.equal(new URLSearchParams(onMap.search).get("f"), "7100000",
|
||||||
|
`the map tab dropped the tune parameters: "${onMap.search}"`);
|
||||||
|
|
||||||
|
// A link naming a rig selects it before applying the rest.
|
||||||
|
await page.goto(`${fixture.origin}/?rig=rig-b&f=${TUNED_HZ}&mode=CW`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const switched = await page.evaluate(() => ({
|
||||||
|
rig: document.getElementById("header-rig-switch-select")?.value ?? null,
|
||||||
|
selected: window.location.search,
|
||||||
|
mode: document.getElementById("mode").value,
|
||||||
|
}));
|
||||||
|
assert.equal(switched.rig, "rig-b", `the link left the client on rig "${switched.rig}"`);
|
||||||
|
assert.equal(switched.mode, "CW", `the link left the radio in ${switched.mode}`);
|
||||||
|
|
||||||
|
// A link with nothing to say tunes nothing, and the address bar fills in
|
||||||
|
// with where the radio already is.
|
||||||
|
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const bare = await dial();
|
||||||
|
assert.equal(new URLSearchParams(bare.search).get("f"), String(bare.freqHz),
|
||||||
|
`a bare load wrote "${bare.search}" for a radio on ${bare.freqHz} Hz`);
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ const DECODER_REGISTRY = [
|
|||||||
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
|
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
|
||||||
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
|
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
|
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
|
||||||
|
{ id: "sstv", label: "SSTV", activation: "toggle", active_modes: ["USB", "FM"] },
|
||||||
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
|
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||||
@@ -44,6 +45,68 @@ const CONTENT_TYPES = new Map([
|
|||||||
[".woff2", "font/woff2"],
|
[".woff2", "font/woff2"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
|
||||||
|
// that reads it takes the body as CBOR unconditionally. Serving JSON here left
|
||||||
|
// every run exercising the client's retry path instead of its history path.
|
||||||
|
function encodeCbor(value) {
|
||||||
|
const chunks = [];
|
||||||
|
const head = (major, length) => {
|
||||||
|
if (length < 24) return Buffer.from([(major << 5) | length]);
|
||||||
|
if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
|
||||||
|
if (length < 0x10000) {
|
||||||
|
const buffer = Buffer.alloc(3);
|
||||||
|
buffer[0] = (major << 5) | 25;
|
||||||
|
buffer.writeUInt16BE(length, 1);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (length < 0x1_0000_0000) {
|
||||||
|
const buffer = Buffer.alloc(5);
|
||||||
|
buffer[0] = (major << 5) | 26;
|
||||||
|
buffer.writeUInt32BE(length, 1);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
// Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
|
||||||
|
const buffer = Buffer.alloc(9);
|
||||||
|
buffer[0] = (major << 5) | 27;
|
||||||
|
buffer.writeBigUInt64BE(BigInt(length), 1);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
const write = (item) => {
|
||||||
|
if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
|
||||||
|
if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
|
||||||
|
if (typeof item === "number") {
|
||||||
|
if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
|
||||||
|
if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
|
||||||
|
const buffer = Buffer.alloc(9);
|
||||||
|
buffer[0] = 0xfb;
|
||||||
|
buffer.writeDoubleBE(item, 1);
|
||||||
|
chunks.push(buffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const bytes = Buffer.from(item, "utf8");
|
||||||
|
chunks.push(head(3, bytes.length), bytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
chunks.push(head(4, item.length));
|
||||||
|
item.forEach(write);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(item);
|
||||||
|
chunks.push(head(5, entries.length));
|
||||||
|
for (const [key, entryValue] of entries) {
|
||||||
|
const keyBytes = Buffer.from(key, "utf8");
|
||||||
|
chunks.push(head(3, keyBytes.length), keyBytes);
|
||||||
|
write(entryValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write(value);
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||||
|
|
||||||
function assetPath(urlPath) {
|
function assetPath(urlPath) {
|
||||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||||
// so a deep link or a refresh serves the SPA shell, not a 404.
|
// so a deep link or a refresh serves the SPA shell, not a 404.
|
||||||
@@ -65,10 +128,14 @@ export async function startWebFixture({
|
|||||||
spectrum = false,
|
spectrum = false,
|
||||||
tx = false,
|
tx = false,
|
||||||
meterDb = -70,
|
meterDb = -70,
|
||||||
|
decodes = [],
|
||||||
|
mode = "FM",
|
||||||
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
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,
|
||||||
@@ -112,7 +179,7 @@ export async function startWebFixture({
|
|||||||
signal_meter: spectrum,
|
signal_meter: spectrum,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
|
status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
|
||||||
// Reported only by SDR backends, and what makes the client show the
|
// Reported only by SDR backends, and what makes the client show the
|
||||||
// squelch control at all.
|
// squelch control at all.
|
||||||
filter: spectrum
|
filter: spectrum
|
||||||
@@ -138,6 +205,7 @@ export async function startWebFixture({
|
|||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
lrpt_decode_enabled: false,
|
lrpt_decode_enabled: false,
|
||||||
wefax_decode_enabled: false,
|
wefax_decode_enabled: false,
|
||||||
|
sstv_decode_enabled: false,
|
||||||
recorder_enabled: false,
|
recorder_enabled: false,
|
||||||
clients: 1,
|
clients: 1,
|
||||||
rigctl_clients: 0,
|
rigctl_clients: 0,
|
||||||
@@ -163,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
|
||||||
@@ -172,6 +241,36 @@ export async function startWebFixture({
|
|||||||
|
|
||||||
const server = http.createServer(async (request, response) => {
|
const server = http.createServer(async (request, response) => {
|
||||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||||
|
// Setting a control means the next status carries the new value, the way a
|
||||||
|
// real server echoes what it applied.
|
||||||
|
if (url.pathname === "/set_sdr_squelch") {
|
||||||
|
const enabled = url.searchParams.get("enabled") === "true";
|
||||||
|
const threshold = Number(url.searchParams.get("threshold_db"));
|
||||||
|
if (status.filter) {
|
||||||
|
status.filter.sdr_squelch_enabled = enabled;
|
||||||
|
if (Number.isFinite(threshold)) status.filter.sdr_squelch_threshold_db = threshold;
|
||||||
|
}
|
||||||
|
response.writeHead(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/set_freq") {
|
||||||
|
const hz = Number(url.searchParams.get("hz"));
|
||||||
|
if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) };
|
||||||
|
response.writeHead(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/set_mode") {
|
||||||
|
const next = (url.searchParams.get("mode") || "").toUpperCase();
|
||||||
|
if (next) status.status.mode = next;
|
||||||
|
response.writeHead(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/set_bandwidth") {
|
||||||
|
const hz = Number(url.searchParams.get("hz"));
|
||||||
|
if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz);
|
||||||
|
response.writeHead(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||||
const remote = url.searchParams.get("remote");
|
const remote = url.searchParams.get("remote");
|
||||||
if (remote) {
|
if (remote) {
|
||||||
@@ -228,6 +327,61 @@ export async function startWebFixture({
|
|||||||
request.on("close", () => clearInterval(timer));
|
request.on("close", () => clearInterval(timer));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Decodes arrive on this stream in the server's own shape: a routing
|
||||||
|
// "type" naming the decoder, snake_case fields inside. The mini views, the
|
||||||
|
// map markers and the history all hang off it, and serving nothing left
|
||||||
|
// every one of them untested.
|
||||||
|
if (url.pathname === "/decode/history") {
|
||||||
|
const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
|
||||||
|
response.writeHead(200, { "content-type": "application/cbor" });
|
||||||
|
response.end(encodeCbor(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/decode") {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
});
|
||||||
|
response.write(": decode stream\n\n");
|
||||||
|
// Repeats: a live decoder keeps producing, and the views that collapse
|
||||||
|
// by station or vessel need more than one frame to behave like they do
|
||||||
|
// in front of a radio.
|
||||||
|
let sent = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!decodes.length) return;
|
||||||
|
const decode = decodes[sent++ % decodes.length];
|
||||||
|
// Stamped as they leave: the client prunes anything older than the
|
||||||
|
// retention window, so a fixed epoch would be dropped on arrival.
|
||||||
|
response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`);
|
||||||
|
}, 400);
|
||||||
|
request.on("close", () => clearInterval(timer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The real server pushes rig state here every second or so; serving an
|
||||||
|
// open-but-silent stream meant nothing in the client's state-update path
|
||||||
|
// was ever exercised.
|
||||||
|
if (url.pathname === "/events") {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
connection: "keep-alive",
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
});
|
||||||
|
// Varying, as a real one is: the client skips a frame identical to the
|
||||||
|
// last, so a repeated payload exercises none of the state-update path.
|
||||||
|
// The variation rides on the S-meter rather than the dial — a rig that
|
||||||
|
// wandered a kilohertz every frame could never be told to tune.
|
||||||
|
const frame = () => JSON.stringify({
|
||||||
|
...status,
|
||||||
|
status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } },
|
||||||
|
});
|
||||||
|
response.write(`data: ${frame()}\n\n`);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
response.write(`data: ${frame()}\n\n`);
|
||||||
|
}, 700);
|
||||||
|
request.on("close", () => clearInterval(timer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -175,21 +181,34 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
|
|||||||
static_asset_response(&req, content_type, entry)
|
static_asset_response(&req, content_type, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve a received SSTV picture out of the local cache.
|
||||||
|
#[get("/sstv-images/{filename}")]
|
||||||
|
pub(crate) async fn sstv_image(path: web::Path<String>) -> impl Responder {
|
||||||
|
cached_png("sstv", &path.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/images/{filename}")]
|
#[get("/images/{filename}")]
|
||||||
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
||||||
let filename = path.into_inner();
|
cached_png("wefax", &path.into_inner())
|
||||||
// Reject path traversal attempts.
|
}
|
||||||
|
|
||||||
|
/// Read a PNG out of one of the decoder cache directories.
|
||||||
|
///
|
||||||
|
/// The file name comes from a client, so it is checked rather than trusted: no
|
||||||
|
/// separators, no parent references, and a .png suffix. Everything a decoder
|
||||||
|
/// writes is named that way, and nothing else in the cache is servable.
|
||||||
|
fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
|
||||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
return HttpResponse::BadRequest().body("invalid filename");
|
return HttpResponse::BadRequest().body("invalid filename");
|
||||||
}
|
}
|
||||||
if !filename.ends_with(".png") {
|
if !filename.ends_with(".png") {
|
||||||
return HttpResponse::BadRequest().body("only .png files are accessible");
|
return HttpResponse::BadRequest().body("only .png files are accessible");
|
||||||
}
|
}
|
||||||
let dir = dirs::cache_dir()
|
let file_path = dirs::cache_dir()
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
.join("trx-rs")
|
.join("trx-rs")
|
||||||
.join("wefax");
|
.join(decoder)
|
||||||
let file_path = dir.join(&filename);
|
.join(filename);
|
||||||
match std::fs::read(&file_path) {
|
match std::fs::read(&file_path) {
|
||||||
Ok(data) => HttpResponse::Ok()
|
Ok(data) => HttpResponse::Ok()
|
||||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ struct DecodeHistoryPayload {
|
|||||||
ft2: Vec<trx_core::decode::Ft8Message>,
|
ft2: Vec<trx_core::decode::Ft8Message>,
|
||||||
wspr: Vec<trx_core::decode::WsprMessage>,
|
wspr: Vec<trx_core::decode::WsprMessage>,
|
||||||
wefax: Vec<trx_core::decode::WefaxMessage>,
|
wefax: Vec<trx_core::decode::WefaxMessage>,
|
||||||
|
sstv: Vec<trx_core::decode::SstvMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecodeHistoryPayload {
|
impl DecodeHistoryPayload {
|
||||||
@@ -74,6 +75,7 @@ impl DecodeHistoryPayload {
|
|||||||
+ self.ft2.len()
|
+ self.ft2.len()
|
||||||
+ self.wspr.len()
|
+ self.wspr.len()
|
||||||
+ self.wefax.len()
|
+ self.wefax.len()
|
||||||
|
+ self.sstv.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ fn collect_decode_history(
|
|||||||
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
||||||
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
||||||
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
|
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
|
||||||
|
sstv: crate::server::audio::snapshot_sstv_history(context, rig_filter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,10 +454,40 @@ pub async fn toggle_wefax_decode(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/toggle_sstv_decode")]
|
||||||
|
pub async fn toggle_sstv_decode(
|
||||||
|
query: web::Query<RemoteQuery>,
|
||||||
|
state: web::Data<watch::Receiver<RigState>>,
|
||||||
|
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||||
|
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let q = query.into_inner();
|
||||||
|
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||||
|
send_command(
|
||||||
|
&rig_tx,
|
||||||
|
RigCommand::SetSstvDecodeEnabled(!rig_state.decoders.sstv_decode_enabled),
|
||||||
|
q.remote,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Decoder clear endpoints
|
// Decoder clear endpoints
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[post("/clear_sstv_decode")]
|
||||||
|
pub async fn clear_sstv_decode(
|
||||||
|
query: web::Query<RemoteQuery>,
|
||||||
|
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
send_command(
|
||||||
|
&rig_tx,
|
||||||
|
RigCommand::ResetSstvDecoder,
|
||||||
|
query.into_inner().remote,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/clear_wefax_decode")]
|
#[post("/clear_wefax_decode")]
|
||||||
pub async fn clear_wefax_decode(
|
pub async fn clear_wefax_decode(
|
||||||
query: web::Query<RemoteQuery>,
|
query: web::Query<RemoteQuery>,
|
||||||
|
|||||||
@@ -591,6 +591,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(decoder::toggle_wspr_decode)
|
.service(decoder::toggle_wspr_decode)
|
||||||
.service(decoder::toggle_lrpt_decode)
|
.service(decoder::toggle_lrpt_decode)
|
||||||
.service(decoder::toggle_wefax_decode)
|
.service(decoder::toggle_wefax_decode)
|
||||||
|
.service(decoder::toggle_sstv_decode)
|
||||||
.service(decoder::clear_ais_decode)
|
.service(decoder::clear_ais_decode)
|
||||||
.service(decoder::clear_vdes_decode)
|
.service(decoder::clear_vdes_decode)
|
||||||
.service(decoder::clear_aprs_decode)
|
.service(decoder::clear_aprs_decode)
|
||||||
@@ -602,6 +603,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(decoder::clear_wspr_decode)
|
.service(decoder::clear_wspr_decode)
|
||||||
.service(decoder::clear_lrpt_decode)
|
.service(decoder::clear_lrpt_decode)
|
||||||
.service(decoder::clear_wefax_decode)
|
.service(decoder::clear_wefax_decode)
|
||||||
|
.service(decoder::clear_sstv_decode)
|
||||||
// Bookmark CRUD
|
// Bookmark CRUD
|
||||||
.service(bookmarks::list_bookmarks)
|
.service(bookmarks::list_bookmarks)
|
||||||
.service(bookmarks::create_bookmark)
|
.service(bookmarks::create_bookmark)
|
||||||
@@ -637,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)
|
||||||
@@ -645,6 +648,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(assets::style_css)
|
.service(assets::style_css)
|
||||||
.service(assets::themes_css)
|
.service(assets::themes_css)
|
||||||
.service(assets::wefax_image)
|
.service(assets::wefax_image)
|
||||||
|
.service(assets::sstv_image)
|
||||||
.service(assets::bandplan_json)
|
.service(assets::bandplan_json)
|
||||||
// Vendored DSEG14 Classic font
|
// Vendored DSEG14 Classic font
|
||||||
.service(assets::dseg14_classic_woff2)
|
.service(assets::dseg14_classic_woff2)
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ use tracing::warn;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||||
WsprMessage,
|
WefaxMessage, WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_frontend::FrontendRuntimeContext;
|
use trx_frontend::FrontendRuntimeContext;
|
||||||
|
|
||||||
@@ -335,6 +335,43 @@ fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store a received picture, saving the PNG the server sent into the local
|
||||||
|
/// cache so `/images/` can serve it back.
|
||||||
|
fn record_sstv(context: &FrontendRuntimeContext, mut msg: SstvMessage) {
|
||||||
|
if let Some(ref data) = msg.png_data {
|
||||||
|
if let Some(ref path) = msg.path {
|
||||||
|
if let Some(filename) = std::path::Path::new(path).file_name() {
|
||||||
|
let dir = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
|
.join("trx-rs")
|
||||||
|
.join("sstv");
|
||||||
|
if std::fs::create_dir_all(&dir).is_ok() {
|
||||||
|
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
|
||||||
|
let local_path = dir.join(filename);
|
||||||
|
if let Err(e) = std::fs::write(&local_path, &bytes) {
|
||||||
|
tracing::warn!("SSTV: failed to save local image: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The picture itself is on disk now; keeping a megabyte of base64 per
|
||||||
|
// entry in memory is what the history does not need.
|
||||||
|
msg.png_data = None;
|
||||||
|
|
||||||
|
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||||
|
let mut history = context
|
||||||
|
.decode_history
|
||||||
|
.sstv
|
||||||
|
.lock()
|
||||||
|
.expect("sstv history mutex poisoned");
|
||||||
|
history.push_back((Instant::now(), rig_id, msg));
|
||||||
|
while history.len() > 100 {
|
||||||
|
history.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if the entry's rig_id matches the optional filter.
|
/// Returns `true` if the entry's rig_id matches the optional filter.
|
||||||
/// `None` filter means "all rigs".
|
/// `None` filter means "all rigs".
|
||||||
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
|
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
|
||||||
@@ -526,6 +563,31 @@ pub fn snapshot_wefax_history(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_sstv_history(
|
||||||
|
context: &FrontendRuntimeContext,
|
||||||
|
rig_filter: Option<&str>,
|
||||||
|
) -> Vec<SstvMessage> {
|
||||||
|
let history = context
|
||||||
|
.decode_history
|
||||||
|
.sstv
|
||||||
|
.lock()
|
||||||
|
.expect("sstv history mutex poisoned");
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||||
|
.map(|(_, _, msg)| msg.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_sstv_history(context: &FrontendRuntimeContext) {
|
||||||
|
let mut history = context
|
||||||
|
.decode_history
|
||||||
|
.sstv
|
||||||
|
.lock()
|
||||||
|
.expect("sstv history mutex poisoned");
|
||||||
|
history.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
|
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
|
||||||
let mut history = context
|
let mut history = context
|
||||||
.decode_history
|
.decode_history
|
||||||
@@ -650,6 +712,9 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
|||||||
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
||||||
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
|
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
|
||||||
DecodedMessage::WefaxProgress(_) => {}
|
DecodedMessage::WefaxProgress(_) => {}
|
||||||
|
DecodedMessage::Sstv(msg) => record_sstv(&context, msg),
|
||||||
|
// Progress is for watching, not for keeping.
|
||||||
|
DecodedMessage::SstvProgress(_) => {}
|
||||||
DecodedMessage::LrptImage(_) => {}
|
DecodedMessage::LrptImage(_) => {}
|
||||||
DecodedMessage::LrptProgress(_) => {}
|
DecodedMessage::LrptProgress(_) => {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
|
|||||||
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
|
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
|
||||||
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
|
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
|
||||||
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
|
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
|
||||||
|
/// Server → client: SSTV received picture (JSON `DecodedMessage::Sstv`).
|
||||||
|
pub const AUDIO_MSG_SSTV_DECODE: u8 = 0x1B;
|
||||||
|
/// Server → client: SSTV decode progress, one line at a time
|
||||||
|
/// (JSON `DecodedMessage::SstvProgress`).
|
||||||
|
pub const AUDIO_MSG_SSTV_PROGRESS: u8 = 0x1C;
|
||||||
|
|
||||||
/// Maximum payload size for normal messages (1 MB).
|
/// Maximum payload size for normal messages (1 MB).
|
||||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub enum DecodedMessage {
|
|||||||
Wefax(WefaxMessage),
|
Wefax(WefaxMessage),
|
||||||
#[serde(rename = "wefax_progress")]
|
#[serde(rename = "wefax_progress")]
|
||||||
WefaxProgress(WefaxProgress),
|
WefaxProgress(WefaxProgress),
|
||||||
|
#[serde(rename = "sstv")]
|
||||||
|
Sstv(SstvMessage),
|
||||||
|
#[serde(rename = "sstv_progress")]
|
||||||
|
SstvProgress(SstvProgress),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecodedMessage {
|
impl DecodedMessage {
|
||||||
@@ -52,6 +56,8 @@ impl DecodedMessage {
|
|||||||
Self::LrptProgress(m) => m.rig_id = Some(id),
|
Self::LrptProgress(m) => m.rig_id = Some(id),
|
||||||
Self::Wefax(m) => m.rig_id = Some(id),
|
Self::Wefax(m) => m.rig_id = Some(id),
|
||||||
Self::WefaxProgress(m) => m.rig_id = Some(id),
|
Self::WefaxProgress(m) => m.rig_id = Some(id),
|
||||||
|
Self::Sstv(m) => m.rig_id = Some(id),
|
||||||
|
Self::SstvProgress(m) => m.rig_id = Some(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,8 @@ impl DecodedMessage {
|
|||||||
Self::LrptProgress(m) => m.rig_id.as_deref(),
|
Self::LrptProgress(m) => m.rig_id.as_deref(),
|
||||||
Self::Wefax(m) => m.rig_id.as_deref(),
|
Self::Wefax(m) => m.rig_id.as_deref(),
|
||||||
Self::WefaxProgress(m) => m.rig_id.as_deref(),
|
Self::WefaxProgress(m) => m.rig_id.as_deref(),
|
||||||
|
Self::Sstv(m) => m.rig_id.as_deref(),
|
||||||
|
Self::SstvProgress(m) => m.rig_id.as_deref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,3 +327,52 @@ pub struct WefaxProgress {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A received SSTV picture, complete or cut short.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SstvMessage {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rig_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ts_ms: Option<i64>,
|
||||||
|
/// VIS code the transmission announced itself with.
|
||||||
|
pub vis: u8,
|
||||||
|
/// Mode name, e.g. "Martin M1".
|
||||||
|
pub mode: String,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Image lines actually received, which is the height only if the whole
|
||||||
|
/// frame arrived.
|
||||||
|
pub lines: u16,
|
||||||
|
/// Whether reception reached the bottom of the frame.
|
||||||
|
pub complete: bool,
|
||||||
|
/// Filesystem path to the saved PNG, when one was written.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// Base64-encoded PNG for the trip to a client. Populated when sending and
|
||||||
|
/// stripped before the message is stored in history, which would otherwise
|
||||||
|
/// hold a megabyte per picture.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub png_data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picture arriving, emitted per line so it can be watched.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SstvProgress {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rig_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ts_ms: Option<i64>,
|
||||||
|
pub vis: u8,
|
||||||
|
pub mode: String,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Index of the line this update carries.
|
||||||
|
pub line: u16,
|
||||||
|
/// Base64-encoded RGB triples for that one line.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub line_data: Option<String>,
|
||||||
|
/// Decoder state for display, e.g. "Receiving Martin M1".
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub state: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub enum RigCommand {
|
|||||||
SetWsprDecodeEnabled(bool),
|
SetWsprDecodeEnabled(bool),
|
||||||
SetLrptDecodeEnabled(bool),
|
SetLrptDecodeEnabled(bool),
|
||||||
SetWefaxDecodeEnabled(bool),
|
SetWefaxDecodeEnabled(bool),
|
||||||
|
SetSstvDecodeEnabled(bool),
|
||||||
ResetAprsDecoder,
|
ResetAprsDecoder,
|
||||||
ResetHfAprsDecoder,
|
ResetHfAprsDecoder,
|
||||||
ResetCwDecoder,
|
ResetCwDecoder,
|
||||||
@@ -42,6 +43,7 @@ pub enum RigCommand {
|
|||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
ResetLrptDecoder,
|
ResetLrptDecoder,
|
||||||
ResetWefaxDecoder,
|
ResetWefaxDecoder,
|
||||||
|
ResetSstvDecoder,
|
||||||
SetBandwidth(u32),
|
SetBandwidth(u32),
|
||||||
SetSdrGain(f64),
|
SetSdrGain(f64),
|
||||||
SetSdrLnaGain(f64),
|
SetSdrLnaGain(f64),
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::ResetLrptDecoder
|
| RigCommand::ResetLrptDecoder
|
||||||
| RigCommand::SetWefaxDecodeEnabled(_)
|
| RigCommand::SetWefaxDecodeEnabled(_)
|
||||||
| RigCommand::ResetWefaxDecoder
|
| RigCommand::ResetWefaxDecoder
|
||||||
|
| RigCommand::SetSstvDecodeEnabled(_)
|
||||||
|
| RigCommand::ResetSstvDecoder
|
||||||
| RigCommand::SetBandwidth(_)
|
| RigCommand::SetBandwidth(_)
|
||||||
| RigCommand::SetSdrGain(_)
|
| RigCommand::SetSdrGain(_)
|
||||||
| RigCommand::SetSdrLnaGain(_)
|
| RigCommand::SetSdrLnaGain(_)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ pub struct DecoderConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wefax_decode_enabled: bool,
|
pub wefax_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub sstv_decode_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub recorder_enabled: bool,
|
pub recorder_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ pub struct DecoderResetSeqs {
|
|||||||
pub lrpt_decode_reset_seq: u64,
|
pub lrpt_decode_reset_seq: u64,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub wefax_decode_reset_seq: u64,
|
pub wefax_decode_reset_seq: u64,
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub sstv_decode_reset_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple transceiver state representation held by the rig task.
|
/// Simple transceiver state representation held by the rig task.
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
|
|||||||
background_decode: false,
|
background_decode: false,
|
||||||
bookmark_selectable: true,
|
bookmark_selectable: true,
|
||||||
},
|
},
|
||||||
|
DecoderDescriptor {
|
||||||
|
id: "sstv",
|
||||||
|
label: "SSTV",
|
||||||
|
activation: DecoderActivation::Toggle,
|
||||||
|
// SSTV is sent on sideband on HF and on FM simplex on VHF, and the
|
||||||
|
// decoder cares only about the audio it is handed.
|
||||||
|
active_modes: &["USB", "LSB", "FM", "AM", "DIG"],
|
||||||
|
background_decode: false,
|
||||||
|
bookmark_selectable: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ define_command_mapping! {
|
|||||||
ResetFt2Decoder <=> ResetFt2Decoder,
|
ResetFt2Decoder <=> ResetFt2Decoder,
|
||||||
ResetWsprDecoder <=> ResetWsprDecoder,
|
ResetWsprDecoder <=> ResetWsprDecoder,
|
||||||
ResetLrptDecoder <=> ResetLrptDecoder,
|
ResetLrptDecoder <=> ResetLrptDecoder,
|
||||||
ResetWefaxDecoder <=> ResetWefaxDecoder;
|
ResetWefaxDecoder <=> ResetWefaxDecoder,
|
||||||
|
ResetSstvDecoder <=> ResetSstvDecoder;
|
||||||
|
|
||||||
// ── Single-field struct <=> tuple ────────────────────────────────
|
// ── Single-field struct <=> tuple ────────────────────────────────
|
||||||
field:
|
field:
|
||||||
@@ -140,6 +141,7 @@ define_command_mapping! {
|
|||||||
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
|
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
|
||||||
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
|
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
|
||||||
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
|
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
|
||||||
|
SetSstvDecodeEnabled { enabled } <=> SetSstvDecodeEnabled,
|
||||||
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
|
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
|
||||||
SetSdrGain { gain_db } <=> SetSdrGain,
|
SetSdrGain { gain_db } <=> SetSdrGain,
|
||||||
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ pub enum ClientCommand {
|
|||||||
SetWefaxDecodeEnabled {
|
SetWefaxDecodeEnabled {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
},
|
},
|
||||||
|
SetSstvDecodeEnabled {
|
||||||
|
enabled: bool,
|
||||||
|
},
|
||||||
ResetAprsDecoder,
|
ResetAprsDecoder,
|
||||||
ResetHfAprsDecoder,
|
ResetHfAprsDecoder,
|
||||||
ResetCwDecoder,
|
ResetCwDecoder,
|
||||||
@@ -82,6 +85,7 @@ pub enum ClientCommand {
|
|||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
ResetLrptDecoder,
|
ResetLrptDecoder,
|
||||||
ResetWefaxDecoder,
|
ResetWefaxDecoder,
|
||||||
|
ResetSstvDecoder,
|
||||||
SetBandwidth {
|
SetBandwidth {
|
||||||
bandwidth_hz: u32,
|
bandwidth_hz: u32,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ trx-cw = { path = "../decoders/trx-cw" }
|
|||||||
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
||||||
trx-ftx = { path = "../decoders/trx-ftx" }
|
trx-ftx = { path = "../decoders/trx-ftx" }
|
||||||
trx-wefax = { path = "../decoders/trx-wefax" }
|
trx-wefax = { path = "../decoders/trx-wefax" }
|
||||||
|
trx-sstv = { path = "../decoders/trx-sstv" }
|
||||||
trx-wspr = { path = "../decoders/trx-wspr" }
|
trx-wspr = { path = "../decoders/trx-wspr" }
|
||||||
trx-wxsat = { path = "../decoders/trx-wxsat" }
|
trx-wxsat = { path = "../decoders/trx-wxsat" }
|
||||||
trx-protocol = { path = "../trx-protocol" }
|
trx-protocol = { path = "../trx-protocol" }
|
||||||
|
|||||||
+252
-4
@@ -30,10 +30,11 @@ use trx_core::audio::{
|
|||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
|
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_SSTV_DECODE, AUDIO_MSG_SSTV_PROGRESS,
|
||||||
AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ,
|
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW,
|
||||||
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
|
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
|
||||||
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE,
|
||||||
|
AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
|
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
|
||||||
@@ -292,6 +293,11 @@ fn build_history_blob(histories: &DecoderHistories) -> (Vec<u8>, usize) {
|
|||||||
DecodedMessage::Wefax,
|
DecodedMessage::Wefax,
|
||||||
AUDIO_MSG_WEFAX_DECODE
|
AUDIO_MSG_WEFAX_DECODE
|
||||||
);
|
);
|
||||||
|
push_history!(
|
||||||
|
histories.snapshot_sstv_history(),
|
||||||
|
DecodedMessage::Sstv,
|
||||||
|
AUDIO_MSG_SSTV_DECODE
|
||||||
|
);
|
||||||
|
|
||||||
(blob, count)
|
(blob, count)
|
||||||
}
|
}
|
||||||
@@ -2444,6 +2450,244 @@ pub async fn run_wefax_decoder(
|
|||||||
info!("WEFAX decoder stopped");
|
info!("WEFAX decoder stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSTV decoder task
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Turn a decoded picture into the message clients receive, saving the PNG on
|
||||||
|
/// the way. The picture travels as base64 so a client on another machine has
|
||||||
|
/// it without a second request; the server keeps its own copy on disk.
|
||||||
|
fn sstv_message(
|
||||||
|
image: &trx_sstv::SstvImage,
|
||||||
|
output_dir: &std::path::Path,
|
||||||
|
freq_hz: u64,
|
||||||
|
) -> DecodedMessage {
|
||||||
|
let path = match image.save_png(output_dir, freq_hz) {
|
||||||
|
Ok(path) => Some(path.to_string_lossy().into_owned()),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("SSTV: failed to save image: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let png_data = image.to_png_base64().ok();
|
||||||
|
DecodedMessage::Sstv(trx_core::decode::SstvMessage {
|
||||||
|
rig_id: None,
|
||||||
|
ts_ms: Some(image.started_ms),
|
||||||
|
vis: image.vis,
|
||||||
|
mode: image.mode.to_string(),
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
lines: image.lines,
|
||||||
|
complete: image.complete,
|
||||||
|
path,
|
||||||
|
png_data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the SSTV decoder task. Processes PCM when enabled and the rig mode is
|
||||||
|
/// one SSTV is sent in.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn run_sstv_decoder(
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
|
output_dir: std::path::PathBuf,
|
||||||
|
) {
|
||||||
|
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||||
|
|
||||||
|
info!("SSTV decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
|
|
||||||
|
let mut decoder = SstvDecoder::new(sample_rate, SstvConfig::default());
|
||||||
|
let mut was_active = false;
|
||||||
|
let mut last_reset_seq: u64 = 0;
|
||||||
|
|
||||||
|
let is_sstv_mode = |mode: &RigMode| {
|
||||||
|
matches!(
|
||||||
|
mode,
|
||||||
|
RigMode::USB | RigMode::LSB | RigMode::FM | RigMode::AM | RigMode::DIG
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset returns any picture in progress rather than discarding it: a
|
||||||
|
// half-received frame is still worth keeping.
|
||||||
|
macro_rules! flush {
|
||||||
|
($decoder:expr) => {
|
||||||
|
for event in $decoder.reset() {
|
||||||
|
if let SstvEvent::Complete(image) = event {
|
||||||
|
// Read the dial now rather than caching it: the picture is
|
||||||
|
// named for where it was received.
|
||||||
|
let freq_hz = state_rx.borrow().status.freq.hz;
|
||||||
|
let msg = sstv_message(&image, &output_dir, freq_hz);
|
||||||
|
if let DecodedMessage::Sstv(ref stored) = msg {
|
||||||
|
histories.record_sstv_message(stored.clone());
|
||||||
|
}
|
||||||
|
let _ = decode_tx.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = state_rx.borrow().decoders.sstv_decode_enabled
|
||||||
|
&& is_sstv_mode(&state_rx.borrow().status.mode);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !active {
|
||||||
|
match state_rx.changed().await {
|
||||||
|
Ok(()) => {
|
||||||
|
let state = state_rx.borrow();
|
||||||
|
active = state.decoders.sstv_decode_enabled && is_sstv_mode(&state.status.mode);
|
||||||
|
if active {
|
||||||
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
recv = pcm_rx.recv() => {
|
||||||
|
match recv {
|
||||||
|
Ok(frame) => {
|
||||||
|
let (process_enabled, reset_seq) = {
|
||||||
|
let state = state_rx.borrow();
|
||||||
|
(
|
||||||
|
state.decoders.sstv_decode_enabled
|
||||||
|
&& is_sstv_mode(&state.status.mode),
|
||||||
|
state.reset_seqs.sstv_decode_reset_seq,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if reset_seq != last_reset_seq {
|
||||||
|
last_reset_seq = reset_seq;
|
||||||
|
flush!(decoder);
|
||||||
|
info!("SSTV decoder reset (seq={})", last_reset_seq);
|
||||||
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !process_enabled {
|
||||||
|
if was_active {
|
||||||
|
flush!(decoder);
|
||||||
|
was_active = false;
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mono = if channels > 1 {
|
||||||
|
let num_frames = frame.len() / channels as usize;
|
||||||
|
let mut mono = Vec::with_capacity(num_frames);
|
||||||
|
for i in 0..num_frames {
|
||||||
|
mono.push(frame[i * channels as usize]);
|
||||||
|
}
|
||||||
|
mono
|
||||||
|
} else {
|
||||||
|
frame
|
||||||
|
};
|
||||||
|
|
||||||
|
was_active = true;
|
||||||
|
let events = tokio::task::block_in_place(|| {
|
||||||
|
let _span = info_span!("sstv_decode").entered();
|
||||||
|
decoder.process_samples(&mono)
|
||||||
|
});
|
||||||
|
|
||||||
|
let latest_reset_seq = state_rx.borrow().reset_seqs.sstv_decode_reset_seq;
|
||||||
|
if latest_reset_seq != reset_seq {
|
||||||
|
last_reset_seq = latest_reset_seq;
|
||||||
|
flush!(decoder);
|
||||||
|
info!("SSTV decoder reset (seq={})", last_reset_seq);
|
||||||
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
SstvEvent::Started { vis, mode, width, height } => {
|
||||||
|
info!("SSTV: receiving {} (VIS {})", mode, vis);
|
||||||
|
let _ = decode_tx.send(DecodedMessage::SstvProgress(
|
||||||
|
trx_core::decode::SstvProgress {
|
||||||
|
rig_id: None,
|
||||||
|
ts_ms: Some(now_ms()),
|
||||||
|
vis,
|
||||||
|
mode: mode.to_string(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
line: 0,
|
||||||
|
line_data: None,
|
||||||
|
state: Some(format!("Receiving {mode}")),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SstvEvent::Row { line, rgb } => {
|
||||||
|
// One row per message: the picture is meant
|
||||||
|
// to be watched arriving, and a row of a
|
||||||
|
// 640-wide mode is under 2 kB.
|
||||||
|
let _ = decode_tx.send(DecodedMessage::SstvProgress(
|
||||||
|
trx_core::decode::SstvProgress {
|
||||||
|
rig_id: None,
|
||||||
|
ts_ms: Some(now_ms()),
|
||||||
|
vis: 0,
|
||||||
|
mode: String::new(),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
line,
|
||||||
|
line_data: Some({
|
||||||
|
use base64::Engine as _;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.encode(&rgb)
|
||||||
|
}),
|
||||||
|
state: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SstvEvent::Complete(image) => {
|
||||||
|
info!(
|
||||||
|
"SSTV: {} picture, {} of {} lines",
|
||||||
|
image.mode, image.lines, image.height
|
||||||
|
);
|
||||||
|
let freq_hz = state_rx.borrow().status.freq.hz;
|
||||||
|
let msg = sstv_message(&image, &output_dir, freq_hz);
|
||||||
|
if let DecodedMessage::Sstv(ref stored) = msg {
|
||||||
|
histories.record_sstv_message(stored.clone());
|
||||||
|
}
|
||||||
|
let _ = decode_tx.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
warn!("SSTV decoder: dropped {} PCM frames", n);
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = state_rx.changed() => {
|
||||||
|
match changed {
|
||||||
|
Ok(()) => {
|
||||||
|
let state = state_rx.borrow();
|
||||||
|
active = state.decoders.sstv_decode_enabled
|
||||||
|
&& is_sstv_mode(&state.status.mode);
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("SSTV decoder stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the epoch.
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Virtual-channel audio support
|
// Virtual-channel audio support
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -3078,6 +3322,8 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
||||||
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
||||||
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
||||||
|
DecodedMessage::Sstv(_) => AUDIO_MSG_SSTV_DECODE,
|
||||||
|
DecodedMessage::SstvProgress(_) => AUDIO_MSG_SSTV_PROGRESS,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||||
@@ -3110,6 +3356,8 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
||||||
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
||||||
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
||||||
|
DecodedMessage::Sstv(_) => AUDIO_MSG_SSTV_DECODE,
|
||||||
|
DecodedMessage::SstvProgress(_) => AUDIO_MSG_SSTV_PROGRESS,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, SstvMessage, VdesMessage, WefaxMessage,
|
||||||
|
WsprMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::history_policy::{
|
use crate::history_policy::{
|
||||||
@@ -36,6 +37,7 @@ pub struct DecoderHistories {
|
|||||||
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||||
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
||||||
|
pub sstv: Mutex<VecDeque<(Instant, SstvMessage)>>,
|
||||||
/// Approximate total entry count across all decoders, maintained
|
/// Approximate total entry count across all decoders, maintained
|
||||||
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
||||||
total_count: AtomicUsize,
|
total_count: AtomicUsize,
|
||||||
@@ -55,6 +57,7 @@ impl DecoderHistories {
|
|||||||
wspr: Mutex::new(VecDeque::new()),
|
wspr: Mutex::new(VecDeque::new()),
|
||||||
lrpt: Mutex::new(VecDeque::new()),
|
lrpt: Mutex::new(VecDeque::new()),
|
||||||
wefax: Mutex::new(VecDeque::new()),
|
wefax: Mutex::new(VecDeque::new()),
|
||||||
|
sstv: Mutex::new(VecDeque::new()),
|
||||||
total_count: AtomicUsize::new(0),
|
total_count: AtomicUsize::new(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -412,6 +415,42 @@ impl DecoderHistories {
|
|||||||
self.adjust_total_count(before, 0);
|
self.adjust_total_count(before, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SSTV ---
|
||||||
|
|
||||||
|
fn prune_sstv(history: &mut VecDeque<(Instant, SstvMessage)>) {
|
||||||
|
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_sstv_message(&self, mut msg: SstvMessage) {
|
||||||
|
if msg.ts_ms.is_none() {
|
||||||
|
msg.ts_ms = Some(current_timestamp_ms());
|
||||||
|
}
|
||||||
|
// The picture is on disk; a megabyte of base64 per entry is not what
|
||||||
|
// the history is for.
|
||||||
|
msg.png_data = None;
|
||||||
|
let mut h = lock_or_recover(&self.sstv, "sstv_history");
|
||||||
|
let before = h.len();
|
||||||
|
h.push_back((Instant::now(), msg));
|
||||||
|
Self::prune_sstv(&mut h);
|
||||||
|
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||||
|
self.adjust_total_count(before, h.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_sstv_history(&self) -> Vec<SstvMessage> {
|
||||||
|
let mut h = lock_or_recover(&self.sstv, "sstv_history");
|
||||||
|
let before = h.len();
|
||||||
|
Self::prune_sstv(&mut h);
|
||||||
|
self.adjust_total_count(before, h.len());
|
||||||
|
h.iter().map(|(_, m)| m.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_sstv_history(&self) {
|
||||||
|
let mut h = lock_or_recover(&self.sstv, "sstv_history");
|
||||||
|
let before = h.len();
|
||||||
|
h.clear();
|
||||||
|
self.adjust_total_count(before, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- WEFAX ---
|
// --- WEFAX ---
|
||||||
|
|
||||||
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
|
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
|
||||||
@@ -483,7 +522,8 @@ impl DecoderHistories {
|
|||||||
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
||||||
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
||||||
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
||||||
+ lock_or_recover(&self.wefax, "wefax_history").len();
|
+ lock_or_recover(&self.wefax, "wefax_history").len()
|
||||||
|
+ lock_or_recover(&self.sstv, "sstv_history").len();
|
||||||
self.total_count.store(total, Ordering::Relaxed);
|
self.total_count.store(total, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
|||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, SstvMessage, VdesMessage, WefaxMessage,
|
||||||
|
WsprMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::audio::DecoderHistories;
|
use crate::audio::DecoderHistories;
|
||||||
@@ -158,6 +159,11 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
|
|||||||
h.push_back(e);
|
h.push_back(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Ok(mut h) = histories.sstv.lock() {
|
||||||
|
for e in load_key::<SstvMessage>(db, &k("sstv")) {
|
||||||
|
h.push_back(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
histories.recalculate_total_count();
|
histories.recalculate_total_count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +229,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
|||||||
drop(h);
|
drop(h);
|
||||||
save_key(db, &k("wefax"), &snapshot);
|
save_key(db, &k("wefax"), &snapshot);
|
||||||
}
|
}
|
||||||
|
if let Ok(h) = histories.sstv.lock() {
|
||||||
|
let snapshot = h.clone();
|
||||||
|
drop(h);
|
||||||
|
save_key(db, &k("sstv"), &snapshot);
|
||||||
|
}
|
||||||
let _ = db.dump();
|
let _ = db.dump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -834,6 +834,25 @@ fn spawn_rig_audio_stack(
|
|||||||
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Spawn SSTV decoder task
|
||||||
|
let sstv_pcm_rx = pcm_tx.subscribe();
|
||||||
|
let sstv_state_rx = state_rx.clone();
|
||||||
|
let sstv_decode_tx = decode_tx.clone();
|
||||||
|
let sstv_sr = rig_cfg.audio.sample_rate;
|
||||||
|
let sstv_ch = rig_cfg.audio.channels;
|
||||||
|
let sstv_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let sstv_histories = histories.clone();
|
||||||
|
let sstv_output_dir = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
|
.join("trx-rs")
|
||||||
|
.join("sstv");
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
|
||||||
|
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if rig_cfg.audio.tx_enabled {
|
if rig_cfg.audio.tx_enabled {
|
||||||
|
|||||||
@@ -657,6 +657,18 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
|
RigCommand::SetSstvDecodeEnabled(en) => {
|
||||||
|
ctx.state.decoders.sstv_decode_enabled = en;
|
||||||
|
info!("SSTV decode {}", if en { "enabled" } else { "disabled" });
|
||||||
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
|
return snapshot_from(ctx.state);
|
||||||
|
}
|
||||||
|
RigCommand::ResetSstvDecoder => {
|
||||||
|
ctx.histories.clear_sstv_history();
|
||||||
|
ctx.state.reset_seqs.sstv_decode_reset_seq += 1;
|
||||||
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
|
return snapshot_from(ctx.state);
|
||||||
|
}
|
||||||
RigCommand::SetBandwidth(hz) => {
|
RigCommand::SetBandwidth(hz) => {
|
||||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
if let Some(sdr) = ctx.rig.as_sdr() {
|
||||||
if let Err(e) = sdr.set_bandwidth(hz).await {
|
if let Err(e) = sdr.set_bandwidth(hz).await {
|
||||||
|
|||||||
Reference in New Issue
Block a user