Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 e52c4839c9 [fix](trx-frontend-http): give satellite passes their own page
CI / lint (pull_request) Successful in 6m30s
CI / test (pull_request) Successful in 8m31s
CI / frontend (pull_request) Successful in 4m30s
CI / reuse (pull_request) Successful in 5s
Pass predictions were a third view inside the Weather Satellite Decoder card,
under Digital modes — a planning tool filed behind a decoder toggle, beside the
FT8 and WEFAX panels it has nothing to do with.  Nothing about knowing when a
bird comes over belongs there.

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

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

Closes #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 23:23:48 +02:00
sjg 77b283cb78 [fix](trx-frontend-http): size the nav's labels against the screen, not the font
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Successful in 4m20s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m35s
CI / frontend (push) Successful in 3m29s
CI / reuse (push) Successful in 2s
CI cut "Bookmarks" off at 360 px where this machine had ten pixels to
spare.  How wide a platform draws a word varies by more than ten per
cent, so a fixed 0.6rem label is a bet on the machine it was measured
on -- the same bet ui-core's own comment warns about where it explains
why the tab strip reflows by measurement rather than at a width.

The long labels are sized with `clamp(0.46rem, 2.2vw, 0.62rem)` now.  A
tab is a fifth of the viewport, so what fits in it follows the viewport;
tying the label to the same thing leaves better than a fifth of the tab
spare at every phone width, and holds with the text drawn 30% wider than
it is here.

The test asked the wrong question too.  "Does the label fit" is a
question about font metrics, and it will keep answering differently on
different machines.  It now asserts what actually matters: a label stays
inside its own tab, and if it is too long for it, it ends in an ellipsis
rather than being cut through a letter.  Both hold whatever width the
platform draws the words at.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:41:45 +02:00
sjg 6284747339 [fix](trx-frontend-http): let the bottom nav's labels fit inside its tabs
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m22s
CI / frontend (pull_request) Failing after 1m46s
CI / reuse (pull_request) Successful in 2s
The bottom nav keeps its labels under the icons -- that is what makes it
navigation rather than five glyphs -- but the labels did not fit the
tabs.  On a 360 px screen "Bookmarks" and "Digital modes" were cut off
mid-word and ran into each other: "Bookmarks igital mode".

The stylesheet already meant to handle it.  Three rules shrink the long
labels, and a rule twenty lines further down sets the size for all of
them; identical specificity, later in the file, so the blanket rule won
and nothing was ever shortened.  Those rules now come after it.

Beyond that, tabs sized themselves to their own labels, so "Map" and
"Digital modes" were given the same room.  They divide the bar evenly
now, which is the shape of every bottom nav, and a label that still runs
long ellipsises rather than being cut through a letter.

"Digital modes" does not fit at any size worth reading, so the nav shows
"Digital" and the button carries the full name as its accessible label;
the short form is hidden from assistive tech, which reads the button's
name instead.

The phone layout test now checks that no label in the nav is cut off at
430, 390 or 360 px, and that the shortened tab still says what it is to
something that reads the page rather than looks at it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:14:31 +02:00
sjg 14ad6e241b [fix](trx-frontend-http): stop the radio controls running off the side of a phone
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m23s
CI / frontend (pull_request) Failing after 1m28s
CI / reuse (pull_request) Successful in 2s
On a 390 px screen the transmit controls were laid out at x=400, off the
side of a tray 354 px wide: present, invisible, and reachable only by a
horizontal scroll with nothing to say it was there.  The page scrolled
sideways by a dozen pixels as well.

Three causes, each in a different place.

A container query at the end of the stylesheet re-imposes `flex-wrap:
wrap` on a narrow tray's rows.  That is right while a row runs left to
right; below the phone breakpoint the row is a column, and wrapping a
column starts a *second column* — which is what put the transmit
controls beside the tray rather than under it.  The rule outranks the
phone one, two classes to its one, and sits later in the file, so it now
excludes itself below that breakpoint rather than being overridden.

The tray is a grid, and a grid column sizes to its content.  One row
wider than the screen — the mode picker, six buttons across — dragged
the whole tray out with it.  `minmax(0, 1fr)` lets the column be as
narrow as the phone and the rows wrap inside it.

The wavelength and signal-strength readouts are given the width of their
column on narrow screens, but with padding on a content box that is the
column's width plus the padding.  Those dozen pixels were the page's
sideways scroll.  They are border-box now.

Also: the rig picker was taking 139 px of a 338 px bar, pushing the rest
of the top bar into the overflow menu.  It is capped and ellipsised on
phones, with the full name still in the menu it opens.

The map's filter bar, collapsed, keeps both its anchors and shrinks
inside them rather than dropping `left` to be sized by shrink-to-fit,
and no longer asks for a compositing layer it does not need.  An
absolutely positioned, backdrop-filtered, composited box sizing itself
from its content is the shape of thing that renders as nothing on an
engine other than the one it was written against — which is what Edge
does with it.  Unverified there: this machine has no Edge to test with.

tests/mobile-layout.mjs holds the page to it at 430, 390 and 360 px: no
sideways scroll, nothing laid out past the right edge, the transmit
controls inside the screen, the rig picker within its cap — and the
collapsed filter bar still on screen with a button to bring the filters
back.

The SSTV panel test stamped its picture with a fixed date, which the
panel's own retention window dropped once that date was a day old.  It
uses a recent stamp now.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 19:06:15 +02:00
sjg 06971ff65c [style](trx-frontend-http): make the spectrum control strip one strip
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m28s
CI / frontend (push) Successful in 4m10s
CI / reuse (push) Successful in 3s
The row of controls under the plot held four different control heights,
units as loose text beside the field they belonged to, and a quarter of
its width as a hole in the middle.  Between about 1100 and 1400 px it
came apart: the bandwidth cluster wrapped to two lines while the level
cluster stayed on one, so the two sat at heights that matched neither
each other nor anything else on the page.

Every control stays, in its order, with its name and its behaviour.
This is the styling and the layout.

A field is now one box -- name, value and unit inside a single border --
so a number cannot be read apart from the unit it is in.  Fields,
buttons, the peak-hold select and the contrast slider are one height,
border-box so a button's own border cannot add two pixels to it, and
2.4rem under a coarse pointer where a fingertip needs the room.  The
contrast readout holds a fixed, tabular slot, so the row no longer
twitches between 1.0 and 0.9.

The container wraps and a cluster does not: a cluster that will not fit
drops whole to the next line and starts it left-aligned.  The slack
goes to a spacer rather than to `space-between`, which is what opened
the hole.

Two things this turned up.  The select carries `status-input` for other
layouts' sake, which drew a box inside the field's box.  And the narrow
-screen rules lived in a media query earlier in the file than the rules
they override -- identical specificity, so the later one won and the
phone layout had been overflowing sideways rather than stacking.  The
narrow rules now sit directly after what they override.

The layout test measures the strip at three widths: one height across
every control, no overflow, inside the plot, and clusters either
sharing a line or each having one -- never one floating against the
middle of the other.

docs/Spectrum-Controls-Rework.md records what was wrong and what was
deliberately left alone: the two different Autos, the settings that do
not persist, the one-shot buttons, and Sweet-spot's silence while it
retunes the SDR.  Those are behaviour, and are for another day.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 01:10:26 +02:00
sjg 18107ce07e [feat](trx-rs): receive SSTV pictures end to end
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 4m12s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
CI / test (pull_request) Successful in 9m37s
CI / test (push) Successful in 7m36s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
Wires the SSTV decoder into the stack, from the audio the server already
has to a panel in the browser that shows the picture arriving.

Server: a decoder task alongside the WEFAX one, running whenever the
decoder is enabled and the rig is in a mode SSTV is sent in.  A finished
picture is written to the cache as a PNG and sent on as a message; the
rows are sent as they decode, so a client can watch two minutes of
Martin M1 fill in rather than waiting for it.  Pictures join the decode
history, are replayed to a client that connects later, and survive a
restart.

Protocol: SetSstvDecodeEnabled and ResetSstvDecoder, a sstv_decode
_enabled flag in the rig state, two audio message types, and Sstv and
SstvProgress on DecodedMessage.  The history stores the message without
its base64 payload -- the picture is already on disk, and a megabyte per
entry is not what a history is for.

Client: pictures land in their own history, and the PNG the server sent
is written to the local cache so /sstv-images/ can serve it back.  That
endpoint and the WEFAX one now share their filename checks rather than
each carrying a copy: no separators, no parent references, .png only.

Web UI: an SSTV sub-tab beside WEFAX, with a live canvas the rows paint
into at the line number they carry, a card for the last picture, and a
filterable history with links to the files.  Rows below the one arriving
are grey rather than black -- not yet received is a different thing from
received as black.  A picture is not a spot, so neither pictures nor
their progress updates reach the decode statistics; that exclusion list
had grown by hand for LRPT and WEFAX and is now one named set.

The decoder crate gains what the server needed to hand a picture on:
to_png, to_png_base64 and save_png, with file names stamped in UTC so
they sort.

Panel behaviour is tested with the plugin runtime: rows painting at
their own line numbers rather than in arrival order, a completed picture
linked by file name alone with no server path in the page, a cut-off
picture reported as partial, clearing, and the toggle following the rig
state.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 00:14:59 +02:00
sjg a0b0c0ed81 [feat](trx-sstv): decode SSTV pictures
CI / test (pull_request) Successful in 7m40s
CI / lint (pull_request) Failing after 14m36s
CI / frontend (pull_request) Successful in 3m12s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m32s
CI / frontend (push) Failing after 1m21s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m16s
A new decoder crate covering the modes SSTV is actually sent in: Martin
M1/M2, Scottie S1/S2/DX, Robot 36/72, PD50 through PD290, and Wraase
SC2-180.  The mode comes from the VIS header every transmission opens
with, so nothing has to be told what is arriving.

Modes are a table rather than code: a list of segments -- sync, gaps,
and one scan per colour channel -- plus a colour model and a geometry.
The decoder reads the offset of each scan straight off that list, which
is what makes fifteen modes cost about as much as one, and a new mode a
table entry.  The segment lists are checked against the published line
durations in a test, because both are transcribed by hand from the same
specification and a digit wrong in one is unlikely to be wrong
identically in the other.

Signal path: band-pass over the SSTV band, Hilbert FIR, instantaneous
frequency by phase difference, then a state machine that walks the
transmission a line at a time.  Each line is looked for where the mode
says it should be and nudged into place by the sync pulse found near
it -- two sound cards never agree exactly, and over the two minutes of a
Martin M1 frame an uncorrected error of a few parts per million shears
the picture visibly.  Rows are emitted as they decode, so a picture can
be watched arriving, which is most of the appeal of the mode.

Four things this cost, each now the reason a piece of it is shaped the
way it is:

The per-sample frequency estimate ripples by ±95 Hz at 1200 Hz, where
the Hilbert approximation is weakest, though its mean is exact.  Pixels
average over their own window and were always right; the VIS bits and
the sync detector classify individual samples and were reading the
ripple.  Both now read short means.  Pixels deliberately still do not,
so edges stay where they are.

Broadband noise cost the whole picture, not part of it: a
phase-difference detector answers whatever is loudest, and there was no
input filter.  Hence the band-pass, which is what every real decoder
does first.

A sync search window shorter than a sync pulse rejected every pulse
arriving late in it, for being short.

The first line's sync search locked onto the VIS stop bit -- 30 ms at
exactly the sync frequency, immediately before the picture starts.  The
header already says where the picture begins, so the first line no
longer searches.

Tests: nine modes are encoded from a test card and decoded back,
compared pixel by pixel, alongside silence around the signal, a
transmission cut off part way, two transmissions back to back, 20 dB of
noise, and a transmitter clock 0.1% fast.  The encoder that produces
those signals reads the same table as the decoder, so a round trip
tests the decoder and not the timings; the timings are held to the
published line durations separately.

Nothing is wired into the server or the web UI yet: this is the decoder
alone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 23:29:52 +02:00
54 changed files with 3295 additions and 872 deletions
Generated
+1
View File
@@ -3300,6 +3300,7 @@ dependencies = [
"trx-ftx",
"trx-protocol",
"trx-reporting",
"trx-sstv",
"trx-vdes",
"trx-wefax",
"trx-wspr",
+116
View File
@@ -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.
@@ -20,14 +20,26 @@ fn main() {
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]
[
(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) }
if ((x + y) / 16) % 2 == 0 {
(240, 240, 40)
} else {
(20, 20, 90)
}
};
rgb[at] = r;
rgb[at + 1] = g;
@@ -39,9 +51,17 @@ fn main() {
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");
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 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());
@@ -58,7 +78,14 @@ fn main() {
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);
std::fs::write(
format!("{dir}/{name}-decoded.png"),
canvas.to_png().expect("png"),
)
.expect("write");
println!(
"{}: {} lines, complete={}",
mode.name, image.lines, image.complete
);
}
}
+85 -5
View File
@@ -79,6 +79,61 @@ pub struct SstvImage {
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,
@@ -192,7 +247,8 @@ impl SstvDecoder {
// 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);
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;
}
@@ -384,7 +440,8 @@ impl SstvDecoder {
if from >= self.freqs.len() {
return None;
}
self.find_sync(from, to, mode).map(|at| self.base + at as u64)
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
@@ -461,7 +518,10 @@ fn sample_scan(
(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()))
(
at.min(freqs.len().saturating_sub(1)),
(at + 1).min(freqs.len()),
)
};
if to <= from {
out.push(0);
@@ -503,14 +563,19 @@ fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<
// 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();
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 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)]
}
@@ -577,3 +642,18 @@ fn finish(reception: &Reception) -> SstvImage {
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));
}
}
+19 -4
View File
@@ -176,7 +176,13 @@ fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
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 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;
@@ -223,7 +229,10 @@ mod tests {
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);
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
}
@@ -276,7 +285,13 @@ mod tests {
// 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");
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"
);
}
}
+39 -10
View File
@@ -29,10 +29,22 @@ pub fn hz_from_level(level: u8) -> f32 {
/// 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
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 {
@@ -40,11 +52,20 @@ pub fn vis_tones(vis: u8) -> Vec<Tone> {
if set {
ones += 1;
}
tones.push(Tone { hz: if set { 1100.0 } else { 1300.0 }, ms: 30.0 });
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.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
}
@@ -97,7 +118,10 @@ pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
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.push(Tone {
hz: hz_from_level(level),
ms: pixel_ms,
});
}
}
}
@@ -149,7 +173,8 @@ fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: boo
/// 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 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
@@ -195,7 +220,11 @@ mod tests {
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 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;
+2 -1
View File
@@ -107,7 +107,8 @@ impl ImageCanvas {
.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()))?;
std::fs::write(&path, self.to_png()?)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
}
+257 -75
View File
@@ -147,22 +147,40 @@ impl SstvMode {
const MARTIN_M1: &[Segment] = &[
Segment::Sync(4.862),
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Green, ms: 146.432 },
Segment::Scan {
channel: Channel::Green,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Blue, ms: 146.432 },
Segment::Scan {
channel: Channel::Blue,
ms: 146.432,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Red, ms: 146.432 },
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::Scan {
channel: Channel::Green,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Blue, ms: 73.216 },
Segment::Scan {
channel: Channel::Blue,
ms: 73.216,
},
Segment::Gap(0.572),
Segment::Scan { channel: Channel::Red, ms: 73.216 },
Segment::Scan {
channel: Channel::Red,
ms: 73.216,
},
Segment::Gap(0.572),
];
@@ -174,32 +192,59 @@ const MARTIN_M2: &[Segment] = &[
const SCOTTIE_S1: &[Segment] = &[
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Green, ms: 138.240 },
Segment::Scan {
channel: Channel::Green,
ms: 138.240,
},
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Blue, ms: 138.240 },
Segment::Scan {
channel: Channel::Blue,
ms: 138.240,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Red, ms: 138.240 },
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::Scan {
channel: Channel::Green,
ms: 88.064,
},
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Blue, ms: 88.064 },
Segment::Scan {
channel: Channel::Blue,
ms: 88.064,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Red, ms: 88.064 },
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::Scan {
channel: Channel::Green,
ms: 345.6,
},
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Blue, ms: 345.6 },
Segment::Scan {
channel: Channel::Blue,
ms: 345.6,
},
Segment::Sync(9.0),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::Red, ms: 345.6 },
Segment::Scan {
channel: Channel::Red,
ms: 345.6,
},
];
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
@@ -215,22 +260,37 @@ const fn scottie_sync_offset(scan_ms: f64) -> f64 {
const ROBOT_36: &[Segment] = &[
Segment::Sync(9.0),
Segment::Gap(3.0),
Segment::Scan { channel: Channel::LumaOdd, ms: 88.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 },
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::Scan {
channel: Channel::LumaOdd,
ms: 138.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::ChromaR, ms: 69.0 },
Segment::Scan {
channel: Channel::ChromaR,
ms: 69.0,
},
Segment::Gap(4.5),
Segment::Gap(1.5),
Segment::Scan { channel: Channel::ChromaB, ms: 69.0 },
Segment::Scan {
channel: Channel::ChromaB,
ms: 69.0,
},
];
// ---------------------------------------------------------------------------
@@ -243,10 +303,22 @@ macro_rules! pd_segments {
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 },
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,
},
];
};
}
@@ -266,87 +338,186 @@ pd_segments!(PD_290, 228.800);
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 },
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,
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,
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,
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,
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,
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),
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),
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),
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,
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,
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,
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,
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,
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,
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,
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,
},
];
@@ -377,7 +548,9 @@ mod tests {
assert!(
(sum - mode.line_ms).abs() < 0.001,
"{}: segments total {:.4} ms, line time says {:.4} ms",
mode.name, sum, mode.line_ms,
mode.name,
sum,
mode.line_ms,
);
}
}
@@ -404,7 +577,13 @@ mod tests {
ColorModel::YCrCb => 3,
ColorModel::YCrCbPaired => 4,
};
assert_eq!(scans.len(), expected, "{} has {} scans", mode.name, scans.len());
assert_eq!(
scans.len(),
expected,
"{} has {} scans",
mode.name,
scans.len()
);
}
}
@@ -417,7 +596,10 @@ mod tests {
let actual = mode.frame_secs();
assert!(
(actual - secs).abs() < 1.5,
"{} takes {:.1} s, expected about {:.0} s", mode.name, actual, secs,
"{} takes {:.1} s, expected about {:.0} s",
mode.name,
actual,
secs,
);
}
}
+12 -3
View File
@@ -174,7 +174,11 @@ mod tests {
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);
assert_eq!(
hit.code, mode.vis,
"{} decoded as VIS {}",
mode.name, hit.code
);
}
}
@@ -188,7 +192,8 @@ mod tests {
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,
"image starts at {}, expected about {expected}",
hit.image_start,
);
}
@@ -199,7 +204,11 @@ mod tests {
// 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 };
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);
}
+87 -18
View File
@@ -134,16 +134,38 @@ fn round_trip(vis: u8, tolerance: f64) {
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 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());
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!(
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);
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!(
@@ -209,13 +231,21 @@ 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 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_eq!(
images.len(),
1,
"expected one picture from a transmission in silence"
);
assert!(images[0].complete);
}
@@ -226,7 +256,11 @@ 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 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.
@@ -234,17 +268,29 @@ fn a_truncated_transmission_still_yields_its_lines() {
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");
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.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,
"{} 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}");
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
@@ -254,7 +300,11 @@ 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 frame = Frame {
width,
height,
rgb: &sent,
};
let one = encode(mode, &frame, SAMPLE_RATE);
let mut audio = one.clone();
@@ -262,8 +312,16 @@ fn decodes_a_second_transmission_after_the_first() {
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");
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
@@ -273,7 +331,11 @@ 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 frame = Frame {
width,
height,
rgb: &sent,
};
let clean = encode(mode, &frame, SAMPLE_RATE);
// Deterministic pseudo-noise at about 20 dB below the signal.
@@ -303,7 +365,11 @@ 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 };
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);
@@ -311,5 +377,8 @@ fn tolerates_a_transmitter_clock_that_runs_fast() {
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");
assert!(
error < 12.0,
"mean error with a fast clock {error:.1} levels"
);
}
+8 -6
View File
@@ -27,11 +27,11 @@ use trx_core::audio::{
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
AUDIO_MSG_WSPR_DECODE,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::DecodedMessage;
use trx_frontend::VChanAudioCmd;
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
| AUDIO_MSG_LRPT_IMAGE
| AUDIO_MSG_LRPT_PROGRESS
| AUDIO_MSG_WEFAX_DECODE
| AUDIO_MSG_WEFAX_PROGRESS,
| AUDIO_MSG_WEFAX_PROGRESS
| AUDIO_MSG_SSTV_DECODE
| AUDIO_MSG_SSTV_PROGRESS,
payload,
)) => {
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
+3
View File
@@ -527,6 +527,9 @@ async fn async_init() -> DynResult<AppState> {
DecodedMessage::LrptProgress(_) => {}
DecodedMessage::Wefax(_) => {}
DecodedMessage::WefaxProgress(_) => {}
// Pictures replay from their own history, not this one.
DecodedMessage::Sstv(_) => {}
DecodedMessage::SstvProgress(_) => {}
}
});
+4 -2
View File
@@ -17,8 +17,8 @@ use uuid::Uuid;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
WefaxMessage, WsprMessage,
};
use trx_core::rig::state::{RigSnapshot, SpectrumData};
use trx_core::{DynResult, RigRequest, RigState};
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
pub ft2: DecodeHistory<Ft8Message>,
pub wspr: DecodeHistory<WsprMessage>,
pub wefax: DecodeHistory<WefaxMessage>,
pub sstv: DecodeHistory<SstvMessage>,
}
impl Default for DecodeHistoryContext {
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
ft2: Arc::new(Mutex::new(VecDeque::new())),
wspr: Arc::new(Mutex::new(VecDeque::new())),
wefax: Arc::new(Mutex::new(VecDeque::new())),
sstv: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
@@ -885,7 +885,7 @@ function elementById(id) {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -932,7 +932,7 @@ function elementById(id) {
["Overview", ["overview"]],
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
["Broadcast & images", ["rds", "sat", "wefax"]]
["Broadcast & images", ["rds", "sat", "wefax", "sstv"]]
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
@@ -1529,6 +1529,7 @@ var TAB_ORDER = [
"bookmarks",
"digital-modes",
"map",
"satellites",
"statistics",
"recorder",
"settings",
@@ -1539,6 +1540,7 @@ var TAB_PATHS = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -1748,6 +1750,7 @@ var pluginGroups = {
"/background-decode.js",
"/sat.js",
"/wefax.js",
"/sstv.js",
"/ais.js",
"/vdes.js",
"/aprs.js",
@@ -1755,6 +1758,7 @@ var pluginGroups = {
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -2240,7 +2244,7 @@ function currentDecodeHistoryRetentionMs() {
}
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
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);
}
};
@@ -4820,6 +4824,9 @@ function render(update) {
for (const [key, entry] of Object.entries(_decoderToggles)) {
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) {
window.syncWefaxToggle(update.wefax_decode_enabled);
}
@@ -5840,6 +5847,7 @@ function _initMapWhenReady() {
}
function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -5869,7 +5877,10 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error) => {
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.();
}).catch((error) => {
console.error(error);
});
if (name === "map") {
@@ -6316,9 +6327,6 @@ function _wireSubTabBar(bar) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -7594,9 +7602,17 @@ function updateDecodeStatus(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) {
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?.scheduleStatsRender();
}
@@ -7642,7 +7658,7 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
}
function restoreDecodeHistoryGroup(kind, messages) {
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) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
}
@@ -2,7 +2,7 @@
(() => {
// src/decode-history-worker.ts
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;
function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset;
@@ -8,7 +8,6 @@ var satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
@@ -17,29 +16,12 @@ var satDom = {
typeFilter: document.getElementById("sat-type-filter"),
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter"),
predMinEl: document.getElementById("sat-pred-min-el"),
predCategory: document.getElementById("sat-pred-category"),
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status")
viewHistoryBtn: document.getElementById("sat-view-history")
};
var satImageHistory = [];
var SAT_MAX_IMAGES = 100;
var SAT_PRED_PAGE_SIZE = 50;
var satPredShowAll = false;
var satFilterText = "";
var satActiveView = "live";
var satPredData = [];
var satPredFilterText = "";
var satPredMinEl = 0;
var satPredCategory = "all";
var satPredSatCount = 0;
var satPredCountdownTimer = null;
function scheduleSatUi(key, job) {
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
satWindow.trxScheduleUiFrameJob(key, job);
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
job();
}
function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
if (view === "history") renderSatHistoryTable();
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => {
switchSatView("live");
});
satDom.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history");
});
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) {
if (!satDom.lrptState) return;
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
}
})();
});
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function getFilteredPredictions() {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
var satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
var satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
var satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
function renderSatPredictions(passes, error) {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
if (satDom.predStatus) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
satWindow.satShowOnMap = function(south, west, north, east) {
if (typeof satWindow.enableMapSourceFilter === "function") {
satWindow.enableMapSourceFilter("sat");
@@ -0,0 +1,228 @@
// src/plugins/satellite-predictions.ts
var predWindow = window;
var dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter"),
minElevation: document.getElementById("sat-pred-min-el"),
category: document.getElementById("sat-pred-category"),
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status")
};
var PAGE_SIZE = 50;
var predShowAll = false;
var predData = [];
var predFilterText = "";
var predMinEl = 0;
var predCategory = "all";
var predSatCount = 0;
var predCountdownTimer = null;
function isPageVisible() {
return !!dom.page && dom.page.style.display !== "none";
}
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function filtered() {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
var filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
var minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
var categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
function render(passes, error) {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
if (dom.status) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
async function load() {
if (dom.status) dom.status.textContent = "Loading predictions…";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
predWindow.refreshSatPredictions = function() {
predShowAll = false;
void load();
};
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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
});
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
@@ -52,14 +53,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span>
</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>
<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 class="tab" data-tab="map">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
<span class="tab-label">Map</span>
</button>
<button class="tab" data-tab="satellites">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
<span class="tab-label">Satellites</span>
</button>
<button class="tab" data-tab="statistics">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
<span class="tab-label">Statistics</span>
@@ -163,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-controls">
<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>
<button id="spectrum-bw-set-btn" type="button">Set</button>
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
<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" class="spectrum-btn" type="button">Set</button>
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
</div>
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
<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">
<option value="0">Off</option>
<option value="500">0.5 s</option>
@@ -182,10 +191,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="60000">60 s</option>
</select>
</label>
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
<button id="spectrum-auto-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-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" 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" class="spectrum-btn" type="button">Auto</button>
<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 id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom &middot; Ctrl+Scroll to tune &middot; Drag to pan &middot; Drag BW edges to resize &middot; +/- zoom &middot; Arrows pan &middot; 0 reset</div>
@@ -584,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="sat">SAT</button>
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
<button class="sub-tab" data-subtab="sstv">SSTV</button>
</div>
<div id="subtab-overview" class="sub-tab-panel">
<div class="plugin-item" data-decoder="ais">
@@ -646,6 +656,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
</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 &mdash; pictures in Martin, Scottie, Robot, PD and Wraase modes
</div>
</div>
<div class="plugin-item" data-decoder="wefax">
<strong>WEFAX Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
@@ -653,6 +669,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
</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> &mdash; 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 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>
@@ -857,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="sat-view-bar">
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
</div>
<!-- Live view -->
<div id="sat-live-view">
@@ -899,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="sat-history-list"></div>
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
</div>
<!-- Predictions view -->
<div id="sat-predictions-view" style="display:none;">
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
</div>
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
@@ -1056,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</template>
</div>
<div id="tab-satellites" class="tab-panel" style="display:none;">
<h2 class="section-heading">Satellites</h2>
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-category" class="sat-sort-select">
<option value="all">All</option>
<option value="weather">Weather</option>
<option value="amateur">Ham Radio</option>
<option value="other">Other</option>
</select>
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<!-- Current passes -->
<div id="sat-pred-current-section">
<div class="sat-pred-section-title">Currently receivable</div>
<div class="sat-pred-header sat-pred-header-current">
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-time">AOS Start</span>
<span class="sat-pred-col-time">AOS End</span>
<span class="sat-pred-col-countdown">Time left</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-current-list"></div>
</div>
<!-- Upcoming passes -->
<div id="sat-pred-upcoming-section">
<div class="sat-pred-section-title">Upcoming passes</div>
<div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span>
<span class="sat-pred-col-el">Max El</span>
<span class="sat-pred-col-dur">Duration</span>
<span class="sat-pred-col-dir">Direction</span>
</div>
<div id="sat-pred-list"></div>
</div>
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
@@ -1717,6 +1717,8 @@ small { color: var(--text-muted); }
flex-shrink: 0;
}
.tab-label { display: block; }
/* Only the bottom nav is short of room. */
.tab-label-short { display: none; }
.tab-more-icon { display: none; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
@@ -2592,7 +2594,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
}
@@ -2610,9 +2611,15 @@ button.map-qso-card:focus-visible {
.map-overlay-filters.is-hidden {
display: none;
}
/* With the filters collapsed the bar has no reason to span the map. */
/* With the filters collapsed the bar has no reason to span the map.
It keeps both anchors and shrinks inside them, rather than dropping `left`
and being sized by shrink-to-fit: the anchored box is a size the browser
already knows, and an absolutely positioned, backdrop-filtered, composited
box being asked to size itself from its content is the shape of thing that
renders as nothing on engines other than the one it was written against. */
.map-overlay-panel.filters-hidden {
left: auto;
width: fit-content;
margin-left: auto;
}
.map-overlay-actions {
display: flex;
@@ -2739,7 +2746,6 @@ button.map-qso-card:focus-visible {
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
will-change: backdrop-filter;
overflow: auto;
}
.map-band-legend.is-empty {
@@ -3810,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
/* One navigation model at every width. Satellites, Statistics, Recorder,
* Settings and About are occasional destinations: they live behind More rather
* than competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="satellites"],
.tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
@@ -3892,7 +3899,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
input.status-input, select.status-input { font-size: 1.1rem; }
:root { --header-waterfall-overlap: 0rem; }
.controls-tray-scroll { overflow-x: auto; }
.controls-tray { width: 100%; min-width: 0; padding-left: 0.85rem; padding-right: 0.85rem; }
/* A grid column sizes to its content by default, so one row wider than the
phone — the mode picker, six buttons across — dragged the whole tray out
with it and left the rest to be found by scrolling sideways. `minmax(0,
1fr)` lets the column be as narrow as the screen and the rows wrap inside
it. */
.controls-tray {
width: 100%;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
padding-left: 0.85rem;
padding-right: 0.85rem;
}
.freq-inline { gap: 0.5rem; flex-wrap: wrap; }
.header-text { width: auto; min-width: 0; flex: 0 1 auto; }
.header-main {
@@ -4013,12 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.tab-icon, .tab-more-icon { display: block; }
/* Shorten long tab labels to keep bottom nav compact */
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
.tab[data-tab="recorder"], .tab[data-tab="settings"],
.tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-menu {
position: fixed;
@@ -4030,7 +4045,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
}
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
/* The bottom nav divides the bar between its five destinations, so a tab is
about a fifth of the screen and the label has to live inside that. Sharing
the width evenly beats sizing each tab to its own label, which gave
"Digital modes" the same room as "Map" and clipped it mid-word. */
.tab-bar-nav .tab {
flex: 1 1 0;
min-width: 0;
padding-left: 0.15rem;
padding-right: 0.15rem;
}
.tab .tab-label, .tab[data-tab] .tab-label {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.75rem;
}
/* The long ones, sized against the screen rather than at a fixed size.
A tab is a fifth of the viewport, so what fits in it is a function of the
viewport — and only roughly of the font size, since how wide a platform
renders a word varies by ten per cent or more between machines. A fixed
0.6rem fit here with ten pixels to spare and clipped in CI.
(These rules also used to sit above the one that sets the size for all
labels — identical specificity, later in the file, so nothing was ever
shortened.) */
.tab[data-tab="bookmarks"] .tab-label,
.tab[data-tab="statistics"] .tab-label {
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
}
/* "Digital modes" does not fit at any size worth reading, so the nav shows
the short form and the button carries the full one as its label. */
.tab[data-tab="digital-modes"] .tab-label { display: none; }
.tab[data-tab="digital-modes"] .tab-label-short {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: clamp(0.46rem, 2.2vw, 0.62rem);
}
.decoder-tab-select {
display: block;
width: 100%;
@@ -4068,10 +4124,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
flex: 0 0 auto;
min-width: 0;
}
/* The rig's name is the widest thing in the bar and it does not need to be:
139 px of a 338 px bar went on it, and everything else was pushed into the
overflow menu to make room. Enough for a short name, ellipsised past that,
with the full name still in the menu it opens. */
.header-rig-switch select,
.header-style-pick select {
width: auto;
min-width: 0;
max-width: 6.5rem;
padding-right: 0.2rem;
font-size: 0.8rem;
text-overflow: ellipsis;
}
.header-bar-btn {
flex: 0 0 auto;
@@ -4102,12 +4166,20 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.mult-col {
min-width: 0;
}
/* These are given the width of their column below; with padding on a
content box that is the column's width *plus* the padding, which is what
pushed the page itself sideways by a dozen pixels. */
.wavelength-display,
.sig-strength-display,
.jog-step,
.jog-mult {
box-sizing: border-box;
width: 100%;
}
.sig-strength-display {
min-width: 0;
font-size: 1.05rem;
}
.jog-step button,
.jog-mult button {
flex: 1 1 0;
@@ -4131,47 +4203,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.signal-measure #sig-result {
width: 100%;
}
#spectrum-controls {
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
padding-top: 0.45rem;
}
#spectrum-bw-row,
#spectrum-level-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.4rem;
}
#spectrum-bw-label,
#spectrum-floor-label,
#spectrum-range-label,
#spectrum-peak-hold-label {
flex: 1 1 100%;
justify-content: space-between;
}
#spectrum-gamma-label {
flex: 1 1 100%;
justify-content: space-between;
}
#spectrum-gamma-input {
flex: 1 1 auto;
min-width: 3rem;
}
#spectrum-bw-input,
#spectrum-floor-input,
#spectrum-range-input,
#overview-peak-hold {
flex: 1 1 auto;
min-width: 3rem;
box-sizing: border-box;
}
#spectrum-bw-set-btn,
#spectrum-bw-auto-btn,
#spectrum-bw-sweet-btn,
#spectrum-auto-btn {
flex: 1 1 auto;
}
.spectrum-edge-shift {
width: 0.95rem;
}
@@ -4821,111 +4852,166 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
white-space: nowrap;
z-index: 10;
}
/* ── Spectrum control strip ────────────────────────────────────────────
Two clusters of controls under the plot: the receiver's bandwidth on the
left, the display's levels on the right. Everything in here is one height
and one shape — a row whose controls each size themselves reads as a pile
rather than a strip.
The container wraps; a cluster does not. A cluster that cannot fit drops
whole to the next line and starts it left-aligned, rather than splitting and
leaving the cluster beside it floating at a height of its own, which is what
the two rows did to each other between 1100 and 1400 px. */
#spectrum-controls {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding: 3px 4px 0;
gap: 0.6rem;
gap: 0.35rem 0.5rem;
font-size: 0.78rem;
color: var(--text-muted);
}
#spectrum-bw-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
#spectrum-bw-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-muted);
min-width: 0;
}
#spectrum-bw-input {
width: 4.5rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
text-align: right;
height: 1.5rem;
}
#spectrum-bw-set-btn,
#spectrum-bw-auto-btn,
#spectrum-bw-sweet-btn {
height: 1.5rem;
min-height: 0;
padding: 0 8px;
font-size: 0.75rem;
}
#spectrum-bw-row,
#spectrum-level-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: nowrap;
gap: 0.3rem;
}
#spectrum-floor-label {
display: flex;
/* Takes the slack, so the clusters reach the ends of a wide strip without
`space-between` opening a quarter-width hole in the middle of it. */
.spectrum-controls-spacer {
flex: 1 1 1.5rem;
min-width: 0;
}
/* A field is one box: name, value and unit share a border, so a number can
never be read apart from the unit it is in. */
.spectrum-field {
display: inline-flex;
align-items: center;
gap: 0.3rem;
/* Border-box on both field and button, or the button's own border and
padding add to the height and the two end up a couple of pixels apart —
which is the pile-of-controls look this is meant to end. */
box-sizing: border-box;
height: 1.7rem;
padding: 0 0.4rem 0 0.5rem;
border: 1px solid var(--border-light);
border-radius: 6px;
background: var(--input-bg);
white-space: nowrap;
font-size: 0.75rem;
color: var(--text-muted);
}
#spectrum-floor-input {
width: 3.4rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
text-align: right;
height: 1.5rem;
.spectrum-field:focus-within {
border-color: color-mix(in srgb, var(--accent-green) 45%, var(--border-light));
}
#spectrum-range-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
.spectrum-field-name {
font-size: 0.72rem;
color: var(--text-muted);
}
#spectrum-range-input {
width: 3.4rem;
padding: 1px 4px;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input-bg);
/* Unit and readout hold a fixed, tabular slot: the row must not twitch as a
value goes from 1.0 to 0.9. */
.spectrum-field-unit,
.spectrum-field-value {
font-size: 0.7rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 1.7rem;
}
.spectrum-field-value {
color: var(--text);
text-align: right;
height: 1.5rem;
}
#spectrum-auto-btn {
height: 1.5rem;
.spectrum-field input[type="number"] {
width: 3.2rem;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
font-size: 0.78rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.spectrum-field input[type="number"]:focus {
outline: none;
}
#spectrum-bw-input {
width: 3.6rem;
}
/* The select carries `status-input` for the sake of other layouts, which gives
it a border and a background of its own; inside a field that reads as a box
drawn inside a box. */
#spectrum-controls .spectrum-field select {
width: auto;
height: 1.35rem;
min-height: 0;
padding: 0 8px;
font-size: 0.75rem;
padding: 0 0.1rem;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text);
font-size: 0.74rem;
}
#spectrum-gamma-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-muted);
}
#spectrum-gamma-input {
width: 5rem;
height: 1.5rem;
.spectrum-field input[type="range"] {
width: 5.5rem;
height: 1.2rem;
cursor: pointer;
}
#spectrum-gamma-value {
font-size: 0.75rem;
min-width: 1.6rem;
text-align: right;
.spectrum-btn {
box-sizing: border-box;
height: 1.7rem;
min-height: 0;
padding: 0 0.6rem;
border-radius: 6px;
font-size: 0.73rem;
font-weight: 600;
white-space: nowrap;
}
/* Fingers need more than a 27 px target; a mouse does not, and the strip has
no width to spare on a desktop. */
@media (hover: none) and (pointer: coarse) {
.spectrum-field,
.spectrum-btn {
height: 2.4rem;
}
}
/* Narrow screens: the same fields, stacked. This sits after the rules it
overrides — `#spectrum-bw-row` in both places has identical specificity, so
whichever comes last wins, and from inside the earlier media query the
narrow rules silently lost. */
@media (max-width: 640px) {
/* Narrow: the clusters stack, and a field takes the width so its name and
its value sit at opposite ends of a line the thumb can hit. */
#spectrum-controls {
flex-direction: column;
align-items: stretch;
gap: 0.45rem;
padding-top: 0.45rem;
}
#spectrum-bw-row,
#spectrum-level-row {
flex-wrap: wrap;
gap: 0.35rem 0.4rem;
}
.spectrum-controls-spacer {
display: none;
}
#spectrum-controls .spectrum-field {
flex: 1 1 100%;
justify-content: space-between;
}
#spectrum-controls .spectrum-field input[type="number"],
#spectrum-controls .spectrum-field input[type="range"],
#spectrum-controls .spectrum-field select {
flex: 1 1 auto;
min-width: 3rem;
}
#spectrum-controls .spectrum-btn {
flex: 1 1 auto;
}
}
.spectrum-hint-mouse,
.spectrum-hint-touch {
@@ -4944,9 +5030,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
/* ── Phone layout (≤ 520px) ───────────────────────────────────────────── */
@media (max-width: 520px) {
/* Single-column controls: jog first, then mode, then power */
/* Single-column controls: jog first, then mode, then power.
`nowrap` is the whole point of it. The base rule wraps, and a *column*
that wraps starts a second column when its items are taller than it is —
which put the transmit controls 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. */
.controls-row {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
}
.controls-col-center {
@@ -6135,8 +6227,16 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.controls-tray { container-type: inline-size; container-name: controls; }
.decode-history-table-wrap { container-type: inline-size; container-name: decode-table; }
@container controls (max-width: 600px) {
.controls-tray .controls-row { flex-wrap: wrap; }
/* Wrapping a narrow tray's columns onto more lines is right while the row runs
left to right. Below the phone breakpoint the row is a column, and wrap there
means "start another column" — which is how the transmit controls ended up
beside the tray instead of under it, off the side of the screen. This rule
outranks the phone one (two classes to its one) and sits later in the file,
so it has to exclude itself rather than be overridden. */
@media (min-width: 521px) {
@container controls (max-width: 600px) {
.controls-tray .controls-row { flex-wrap: wrap; }
}
}
@container decode-table (max-width: 500px) {
.decode-history-table th:nth-child(n+4),
@@ -23,12 +23,14 @@ await build({
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
ais: path.join(sourceDir, "plugins", "ais.ts"),
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -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 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";
@@ -111,7 +111,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
/**
* 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, };
@@ -385,7 +385,9 @@ declare global {
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
refreshSatPredictions?(): void;
syncWefaxToggle?(enabled: boolean): void;
syncSstvToggle?(enabled: boolean): void;
updateAisBar?(value?: number): void;
updateVdesBar?(value?: number): void;
updateAprsBar?(value?: number): void;
@@ -858,7 +860,7 @@ function currentDecodeHistoryRetentionMs() {
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
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);
}
};
@@ -3712,7 +3714,11 @@ function render(update: AppUpdate) {
for (const [key, entry] of Object.entries(_decoderToggles)) {
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) {
window.syncWefaxToggle(update.wefax_decode_enabled);
}
@@ -4844,6 +4850,7 @@ function _initMapWhenReady() {
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -4884,7 +4891,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
// Its countdowns tick every second, so the page stops when it is not on screen.
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
// Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
}
@@ -5236,10 +5249,6 @@ function _wireSubTabBar(bar: WiredElement) {
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
});
}
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
window.clearSatPredictionDom();
}
});
}
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
@@ -6567,9 +6576,15 @@ function updateDecodeStatus(text: string) {
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) {
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?.scheduleStatsRender();
}
@@ -6619,7 +6634,7 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
if (!Array.isArray(messages) || messages.length === 0) return;
// 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) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
}
@@ -5,7 +5,7 @@
export {};
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 CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
interface DecodeState { offset: number }
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
satellites: "/satellites",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
@@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
type PluginGroup =
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
| "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
@@ -11,10 +13,11 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// 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", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.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: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
satellites: ["/satellite-predictions.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
@@ -4,13 +4,12 @@
import { hostCore } from "./host.js";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
type SatelliteView = "live" | "history" | "predictions";
type SatelliteView = "live" | "history";
interface SatelliteBridge {
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
clearSatPredictionDom?: () => void;
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void;
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
// --- SAT Plugin ---
// Live view: decoder state, latest image card
// History view: filterable table of all decoded images
// Predictions view: next 24 h passes for ham satellites
//
// Pass predictions live on their own page; see satellite-predictions.ts.
// ── DOM references (cached once) ───────────────────────────────────
const satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
@@ -42,30 +41,13 @@ const satDom = {
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
let satImageHistory: SatelliteImage[] = [];
const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = "";
let satActiveView: SatelliteView = "live";
let satPredData: SatellitePass[] = [];
let satPredFilterText = "";
let satPredMinEl = 0;
let satPredCategory = "all";
let satPredSatCount = 0;
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
// ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key: string, job: () => void): void {
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
// ── View switching ──────────────────────────────────────────────────
function switchSatView(view: SatelliteView): void {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (view === "history") renderSatHistoryTable();
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
// ── Live view: decoder state ────────────────────────────────────────
let lastSatLrptOn: boolean | null = null;
@@ -335,234 +300,6 @@ document
}
})(); });
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function getFilteredPredictions(): SatellitePass[] {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
const satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
const satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
const satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
// ── Status ──
if (satDom.predStatus) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function loadSatPredictions(): Promise<void> {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error: unknown) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Navigate to map centered on satellite image bounds ──────────────
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
if (typeof satWindow.enableMapSourceFilter === "function") {
@@ -0,0 +1,290 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
// --- Satellite pass predictions ---
//
// Its own page rather than a third view inside the weather-satellite decoder:
// when a bird comes over is a planning question, and it was buried under
// Digital modes beside decoders it has nothing to do with.
interface PredictionsBridge {
clearSatPredictionDom?: () => void;
refreshSatPredictions?: () => void;
}
const predWindow = window as unknown as PredictionsBridge;
// ── DOM references (cached once) ───────────────────────────────────
const dom = {
page: document.getElementById("tab-satellites"),
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
currentList: document.getElementById("sat-pred-current-list"),
upcomingList: document.getElementById("sat-pred-list"),
currentSection: document.getElementById("sat-pred-current-section"),
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
status: document.getElementById("sat-pred-status"),
};
// ── State ───────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
let predShowAll = false;
let predData: SatellitePass[] = [];
let predFilterText = "";
let predMinEl = 0;
let predCategory = "all";
let predSatCount = 0;
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
/** The countdowns tick once a second, so they stop when nobody is looking. */
function isPageVisible(): boolean {
return !!dom.page && dom.page.style.display !== "none";
}
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms: number): string {
const d = new Date(ms);
const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (predCountdownTimer) {
clearInterval(predCountdownTimer);
predCountdownTimer = null;
}
}
function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
predCountdownTimer = setInterval(() => {
if (!isPageVisible()) {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
render(filtered());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
function filtered(): SatellitePass[] {
let items = predData;
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
return items;
}
function applyFilters() {
render(filtered());
}
const filterInput = dom.filter;
filterInput?.addEventListener("input", () => {
predFilterText = filterInput.value.trim().toUpperCase();
applyFilters();
});
const minElevationSelect = dom.minElevation;
minElevationSelect?.addEventListener("change", () => {
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
applyFilters();
});
const categorySelect = dom.category;
categorySelect?.addEventListener("change", () => {
predCategory = categorySelect.value;
applyFilters();
});
// ── Predictions: main render ────────────────────────────────────────
function render(passes: SatellitePass[], error?: string): void {
stopCountdownTimer();
if (error) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
if (dom.currentSection) dom.currentSection.style.display = "none";
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
// ── Current passes ──
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
if (dom.currentList) {
if (current.length === 0) {
dom.currentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
dom.currentList.replaceChildren(frag);
}
}
// ── Upcoming passes ──
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
if (dom.upcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
moreRow.addEventListener("click", () => {
predShowAll = true;
render(filtered());
});
frag.appendChild(moreRow);
}
dom.upcomingList.replaceChildren(frag);
}
// ── Status ──
if (dom.status) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
dom.status.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && isPageVisible()) {
startCountdownTimer(dom.currentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function load(): Promise<void> {
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as SatellitePassResponse;
predSatCount = data.satellite_count || 0;
if (data.error) {
predData = [];
render([], data.error);
} else {
predData = data.passes || [];
render(filtered());
}
} catch (error: unknown) {
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ── Page lifecycle ──────────────────────────────────────────────────
function clearPredictionDom(): void {
stopCountdownTimer();
if (dom.currentList) dom.currentList.innerHTML = "";
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
}
predWindow.clearSatPredictionDom = clearPredictionDom;
/**
* Called by the router each time the Satellites page is opened, including the
* visit that imports this module so loading is driven from one place only.
*/
predWindow.refreshSatPredictions = function () {
predShowAll = false;
void load();
};
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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,
});
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
@@ -541,7 +541,7 @@ function elementById<T extends HTMLElement>(id: string): T {
select.setAttribute("aria-label", "Decoder view");
const groups: Array<[string, string[]]> = [
["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]) => {
const group = document.createElement("optgroup");
@@ -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();
}
@@ -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();
}
@@ -196,6 +196,62 @@ try {
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
// exists. It used to fail silently and never retry, so the allocations only
// turned up if the operator reloaded the page by hand.
@@ -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");
});
@@ -29,6 +29,7 @@ const DECODER_REGISTRY = [
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ 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: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
@@ -104,7 +105,7 @@ function encodeCbor(value) {
return Buffer.concat(chunks);
}
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
function assetPath(urlPath) {
// Every tab route has its own index handler on the server (see api/assets.rs),
@@ -134,6 +135,7 @@ export async function startWebFixture({
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -203,6 +205,7 @@ export async function startWebFixture({
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
sstv_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
@@ -228,6 +231,7 @@ export async function startWebFixture({
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
@@ -101,6 +101,12 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
@@ -175,21 +181,34 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
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}")]
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
let filename = path.into_inner();
// Reject path traversal attempts.
cached_png("wefax", &path.into_inner())
}
/// 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("..") {
return HttpResponse::BadRequest().body("invalid filename");
}
if !filename.ends_with(".png") {
return HttpResponse::BadRequest().body("only .png files are accessible");
}
let dir = dirs::cache_dir()
let file_path = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let file_path = dir.join(&filename);
.join(decoder)
.join(filename);
match std::fs::read(&file_path) {
Ok(data) => HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
@@ -60,6 +60,7 @@ struct DecodeHistoryPayload {
ft2: Vec<trx_core::decode::Ft8Message>,
wspr: Vec<trx_core::decode::WsprMessage>,
wefax: Vec<trx_core::decode::WefaxMessage>,
sstv: Vec<trx_core::decode::SstvMessage>,
}
impl DecodeHistoryPayload {
@@ -74,6 +75,7 @@ impl DecodeHistoryPayload {
+ self.ft2.len()
+ self.wspr.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),
wspr: crate::server::audio::snapshot_wspr_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
}
#[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
// ============================================================================
#[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")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
@@ -591,6 +591,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(decoder::toggle_wspr_decode)
.service(decoder::toggle_lrpt_decode)
.service(decoder::toggle_wefax_decode)
.service(decoder::toggle_sstv_decode)
.service(decoder::clear_ais_decode)
.service(decoder::clear_vdes_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_lrpt_decode)
.service(decoder::clear_wefax_decode)
.service(decoder::clear_sstv_decode)
// Bookmark CRUD
.service(bookmarks::list_bookmarks)
.service(bookmarks::create_bookmark)
@@ -637,6 +639,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::satellites_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
@@ -645,6 +648,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::style_css)
.service(assets::themes_css)
.service(assets::wefax_image)
.service(assets::sstv_image)
.service(assets::bandplan_json)
// Vendored DSEG14 Classic font
.service(assets::dseg14_classic_woff2)
@@ -24,8 +24,8 @@ use tracing::warn;
use uuid::Uuid;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
WefaxMessage, WsprMessage,
};
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.
/// `None` filter means "all rigs".
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
@@ -526,6 +563,31 @@ pub fn snapshot_wefax_history(
.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) {
let mut history = context
.decode_history
@@ -650,6 +712,9 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
DecodedMessage::WefaxProgress(_) => {}
DecodedMessage::Sstv(msg) => record_sstv(&context, msg),
// Progress is for watching, not for keeping.
DecodedMessage::SstvProgress(_) => {}
DecodedMessage::LrptImage(_) => {}
DecodedMessage::LrptProgress(_) => {}
},
+5
View File
@@ -74,6 +74,11 @@ pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
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).
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
+57
View File
@@ -36,6 +36,10 @@ pub enum DecodedMessage {
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
#[serde(rename = "sstv")]
Sstv(SstvMessage),
#[serde(rename = "sstv_progress")]
SstvProgress(SstvProgress),
}
impl DecodedMessage {
@@ -52,6 +56,8 @@ impl DecodedMessage {
Self::LrptProgress(m) => m.rig_id = Some(id),
Self::Wefax(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::Wefax(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")]
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>,
}
+2
View File
@@ -33,6 +33,7 @@ pub enum RigCommand {
SetWsprDecodeEnabled(bool),
SetLrptDecodeEnabled(bool),
SetWefaxDecodeEnabled(bool),
SetSstvDecodeEnabled(bool),
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
@@ -42,6 +43,7 @@ pub enum RigCommand {
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
ResetSstvDecoder,
SetBandwidth(u32),
SetSdrGain(f64),
SetSdrLnaGain(f64),
@@ -462,6 +462,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::ResetLrptDecoder
| RigCommand::SetWefaxDecodeEnabled(_)
| RigCommand::ResetWefaxDecoder
| RigCommand::SetSstvDecodeEnabled(_)
| RigCommand::ResetSstvDecoder
| RigCommand::SetBandwidth(_)
| RigCommand::SetSdrGain(_)
| RigCommand::SetSdrLnaGain(_)
+4
View File
@@ -34,6 +34,8 @@ pub struct DecoderConfig {
#[serde(default)]
pub wefax_decode_enabled: bool,
#[serde(default)]
pub sstv_decode_enabled: bool,
#[serde(default)]
pub recorder_enabled: bool,
}
@@ -62,6 +64,8 @@ pub struct DecoderResetSeqs {
pub lrpt_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
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.
+10
View File
@@ -139,6 +139,16 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
background_decode: false,
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,
},
];
// ============================================================================
+3 -1
View File
@@ -122,7 +122,8 @@ define_command_mapping! {
ResetFt2Decoder <=> ResetFt2Decoder,
ResetWsprDecoder <=> ResetWsprDecoder,
ResetLrptDecoder <=> ResetLrptDecoder,
ResetWefaxDecoder <=> ResetWefaxDecoder;
ResetWefaxDecoder <=> ResetWefaxDecoder,
ResetSstvDecoder <=> ResetSstvDecoder;
// ── Single-field struct <=> tuple ────────────────────────────────
field:
@@ -140,6 +141,7 @@ define_command_mapping! {
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
SetSstvDecodeEnabled { enabled } <=> SetSstvDecodeEnabled,
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
+4
View File
@@ -73,6 +73,9 @@ pub enum ClientCommand {
SetWefaxDecodeEnabled {
enabled: bool,
},
SetSstvDecodeEnabled {
enabled: bool,
},
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
@@ -82,6 +85,7 @@ pub enum ClientCommand {
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
ResetSstvDecoder,
SetBandwidth {
bandwidth_hz: u32,
},
+1
View File
@@ -41,6 +41,7 @@ trx-cw = { path = "../decoders/trx-cw" }
trx-decode-log = { path = "../decoders/trx-decode-log" }
trx-ftx = { path = "../decoders/trx-ftx" }
trx-wefax = { path = "../decoders/trx-wefax" }
trx-sstv = { path = "../decoders/trx-sstv" }
trx-wspr = { path = "../decoders/trx-wspr" }
trx-wxsat = { path = "../decoders/trx-wxsat" }
trx-protocol = { path = "../trx-protocol" }
+252 -4
View File
@@ -30,10 +30,11 @@ use trx_core::audio::{
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ,
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_SSTV_DECODE, AUDIO_MSG_SSTV_PROGRESS,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW,
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE,
AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
};
#[cfg(test)]
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
@@ -292,6 +293,11 @@ fn build_history_blob(histories: &DecoderHistories) -> (Vec<u8>, usize) {
DecodedMessage::Wefax,
AUDIO_MSG_WEFAX_DECODE
);
push_history!(
histories.snapshot_sstv_history(),
DecodedMessage::Sstv,
AUDIO_MSG_SSTV_DECODE
);
(blob, count)
}
@@ -2444,6 +2450,244 @@ pub async fn run_wefax_decoder(
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
// ---------------------------------------------------------------------------
@@ -3078,6 +3322,8 @@ async fn handle_audio_client(
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
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 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::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
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 Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
+42 -2
View File
@@ -11,7 +11,8 @@ use std::time::Instant;
use base64::Engine as _;
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::{
@@ -36,6 +37,7 @@ pub struct DecoderHistories {
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
pub sstv: Mutex<VecDeque<(Instant, SstvMessage)>>,
/// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
total_count: AtomicUsize,
@@ -55,6 +57,7 @@ impl DecoderHistories {
wspr: Mutex::new(VecDeque::new()),
lrpt: Mutex::new(VecDeque::new()),
wefax: Mutex::new(VecDeque::new()),
sstv: Mutex::new(VecDeque::new()),
total_count: AtomicUsize::new(0),
})
}
@@ -412,6 +415,42 @@ impl DecoderHistories {
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 ---
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.wspr, "wspr_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);
}
}
+12 -1
View File
@@ -19,7 +19,8 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
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;
@@ -158,6 +159,11 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
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();
}
@@ -223,6 +229,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h);
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();
}
+19
View File
@@ -834,6 +834,25 @@ fn spawn_rig_audio_stack(
_ = 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 {
+12
View File
@@ -657,6 +657,18 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
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) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_bandwidth(hz).await {