[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>
This commit was merged in pull request #44.
This commit is contained in:
sjg
2026-08-06 00:14:59 +02:00
parent a0b0c0ed81
commit 18107ce07e
36 changed files with 1517 additions and 38 deletions
+70
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,
@@ -587,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));
}
}