[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
+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.