[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
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:
Generated
+1
@@ -3300,6 +3300,7 @@ dependencies = [
|
|||||||
"trx-ftx",
|
"trx-ftx",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"trx-reporting",
|
"trx-reporting",
|
||||||
|
"trx-sstv",
|
||||||
"trx-vdes",
|
"trx-vdes",
|
||||||
"trx-wefax",
|
"trx-wefax",
|
||||||
"trx-wspr",
|
"trx-wspr",
|
||||||
|
|||||||
@@ -79,6 +79,61 @@ pub struct SstvImage {
|
|||||||
pub started_ms: i64,
|
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 {
|
enum State {
|
||||||
/// Listening for a header.
|
/// Listening for a header.
|
||||||
Searching,
|
Searching,
|
||||||
@@ -587,3 +642,18 @@ fn finish(reception: &Reception) -> SstvImage {
|
|||||||
started_ms: reception.started_ms,
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ use trx_core::audio::{
|
|||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||||
AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
use trx_frontend::VChanAudioCmd;
|
use trx_frontend::VChanAudioCmd;
|
||||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
|||||||
| AUDIO_MSG_LRPT_IMAGE
|
| AUDIO_MSG_LRPT_IMAGE
|
||||||
| AUDIO_MSG_LRPT_PROGRESS
|
| AUDIO_MSG_LRPT_PROGRESS
|
||||||
| AUDIO_MSG_WEFAX_DECODE
|
| AUDIO_MSG_WEFAX_DECODE
|
||||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
| AUDIO_MSG_WEFAX_PROGRESS
|
||||||
|
| AUDIO_MSG_SSTV_DECODE
|
||||||
|
| AUDIO_MSG_SSTV_PROGRESS,
|
||||||
payload,
|
payload,
|
||||||
)) => {
|
)) => {
|
||||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
DecodedMessage::LrptProgress(_) => {}
|
DecodedMessage::LrptProgress(_) => {}
|
||||||
DecodedMessage::Wefax(_) => {}
|
DecodedMessage::Wefax(_) => {}
|
||||||
DecodedMessage::WefaxProgress(_) => {}
|
DecodedMessage::WefaxProgress(_) => {}
|
||||||
|
// Pictures replay from their own history, not this one.
|
||||||
|
DecodedMessage::Sstv(_) => {}
|
||||||
|
DecodedMessage::SstvProgress(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||||
WsprMessage,
|
WefaxMessage, WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||||
use trx_core::{DynResult, RigRequest, RigState};
|
use trx_core::{DynResult, RigRequest, RigState};
|
||||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
|||||||
pub ft2: DecodeHistory<Ft8Message>,
|
pub ft2: DecodeHistory<Ft8Message>,
|
||||||
pub wspr: DecodeHistory<WsprMessage>,
|
pub wspr: DecodeHistory<WsprMessage>,
|
||||||
pub wefax: DecodeHistory<WefaxMessage>,
|
pub wefax: DecodeHistory<WefaxMessage>,
|
||||||
|
pub sstv: DecodeHistory<SstvMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DecodeHistoryContext {
|
impl Default for DecodeHistoryContext {
|
||||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
|||||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -932,7 +932,7 @@ function elementById(id) {
|
|||||||
["Overview", ["overview"]],
|
["Overview", ["overview"]],
|
||||||
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
||||||
["Broadcast & images", ["rds", "sat", "wefax"]]
|
["Broadcast & images", ["rds", "sat", "wefax", "sstv"]]
|
||||||
];
|
];
|
||||||
groups.forEach(([label, ids]) => {
|
groups.forEach(([label, ids]) => {
|
||||||
const group = document.createElement("optgroup");
|
const group = document.createElement("optgroup");
|
||||||
@@ -1748,6 +1748,7 @@ var pluginGroups = {
|
|||||||
"/background-decode.js",
|
"/background-decode.js",
|
||||||
"/sat.js",
|
"/sat.js",
|
||||||
"/wefax.js",
|
"/wefax.js",
|
||||||
|
"/sstv.js",
|
||||||
"/ais.js",
|
"/ais.js",
|
||||||
"/vdes.js",
|
"/vdes.js",
|
||||||
"/aprs.js",
|
"/aprs.js",
|
||||||
@@ -2240,7 +2241,7 @@ function currentDecodeHistoryRetentionMs() {
|
|||||||
}
|
}
|
||||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||||
window.applyDecodeHistoryRetention = function() {
|
window.applyDecodeHistoryRetention = function() {
|
||||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||||
window.trxPluginRuntime.prune(decoder);
|
window.trxPluginRuntime.prune(decoder);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -4820,6 +4821,9 @@ function render(update) {
|
|||||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||||
}
|
}
|
||||||
|
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||||
|
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||||
|
}
|
||||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||||
}
|
}
|
||||||
@@ -7594,9 +7598,17 @@ function updateDecodeStatus(text) {
|
|||||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
||||||
|
"lrpt_image",
|
||||||
|
"lrpt_progress",
|
||||||
|
"wefax",
|
||||||
|
"wefax_progress",
|
||||||
|
"sstv",
|
||||||
|
"sstv_progress"
|
||||||
|
]);
|
||||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
@@ -7642,7 +7654,7 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
|
|||||||
}
|
}
|
||||||
function restoreDecodeHistoryGroup(kind, messages) {
|
function restoreDecodeHistoryGroup(kind, messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
// src/decode-history-worker.ts
|
// src/decode-history-worker.ts
|
||||||
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||||
var workerScope = self;
|
var workerScope = self;
|
||||||
function decodeCborUint(view, bytes, state, additional) {
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
const offset = state.offset;
|
const offset = state.offset;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/sstv.ts
|
||||||
|
var sstvWindow = window;
|
||||||
|
var sstvDom = {
|
||||||
|
status: document.getElementById("sstv-status"),
|
||||||
|
liveView: document.getElementById("sstv-live-view"),
|
||||||
|
historyView: document.getElementById("sstv-history-view"),
|
||||||
|
liveContainer: document.getElementById("sstv-live-container"),
|
||||||
|
liveInfo: document.getElementById("sstv-live-info"),
|
||||||
|
liveCanvas: document.getElementById("sstv-live-canvas"),
|
||||||
|
liveLatest: document.getElementById("sstv-live-latest"),
|
||||||
|
historyList: document.getElementById("sstv-history-list"),
|
||||||
|
historyCount: document.getElementById("sstv-history-count"),
|
||||||
|
filterInput: document.getElementById("sstv-filter"),
|
||||||
|
sortSelect: document.getElementById("sstv-sort"),
|
||||||
|
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("sstv-view-history")
|
||||||
|
};
|
||||||
|
var SSTV_MAX_IMAGES = 100;
|
||||||
|
var sstvHistory = [];
|
||||||
|
var liveCtx = null;
|
||||||
|
var liveMode = "";
|
||||||
|
var liveHeight = 0;
|
||||||
|
var liveRows = 0;
|
||||||
|
var activeView = "live";
|
||||||
|
var filterText = "";
|
||||||
|
function retentionMs() {
|
||||||
|
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneHistory() {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||||
|
}
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleUi(key, job) {
|
||||||
|
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function imageUrl(image) {
|
||||||
|
if (!image.path) return null;
|
||||||
|
const filename = image.path.split(/[\\/]/).pop();
|
||||||
|
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||||
|
}
|
||||||
|
function decodeBase64(data) {
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
function switchView(view) {
|
||||||
|
activeView = view;
|
||||||
|
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||||
|
button?.classList.remove("sat-view-active");
|
||||||
|
}
|
||||||
|
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||||
|
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderHistoryTable();
|
||||||
|
}
|
||||||
|
sstvDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
|
switchView("live");
|
||||||
|
});
|
||||||
|
sstvDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
|
switchView("history");
|
||||||
|
});
|
||||||
|
function beginPicture(mode, width, height) {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!canvas || width <= 0 || height <= 0) return;
|
||||||
|
liveMode = mode;
|
||||||
|
liveHeight = height;
|
||||||
|
liveRows = 0;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
liveCtx = canvas.getContext("2d");
|
||||||
|
if (!liveCtx) return;
|
||||||
|
liveCtx.fillStyle = "#606060";
|
||||||
|
liveCtx.fillRect(0, 0, width, height);
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||||
|
updateLiveInfo();
|
||||||
|
}
|
||||||
|
function updateLiveInfo() {
|
||||||
|
if (!sstvDom.liveInfo) return;
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||||
|
sstvDom.liveInfo.textContent = liveMode ? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}` : "";
|
||||||
|
}
|
||||||
|
function paintRow(line, rgb) {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!liveCtx || !canvas) return;
|
||||||
|
const width = canvas.width;
|
||||||
|
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||||
|
const row = liveCtx.createImageData(width, 1);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||||
|
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||||
|
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||||
|
row.data[x * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
liveCtx.putImageData(row, 0, line);
|
||||||
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
|
}
|
||||||
|
function onProgress(msg) {
|
||||||
|
if (msg.state) {
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||||
|
const line = msg.line;
|
||||||
|
const rgb = decodeBase64(msg.line_data);
|
||||||
|
scheduleUi(`sstv-row-${line}`, () => {
|
||||||
|
paintRow(line, rgb);
|
||||||
|
updateLiveInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onImage(msg) {
|
||||||
|
const image = { ...msg };
|
||||||
|
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||||
|
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||||
|
sstvHistory.push(image);
|
||||||
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
|
pruneHistory();
|
||||||
|
if (sstvDom.status) {
|
||||||
|
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
|
}
|
||||||
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
|
}
|
||||||
|
function renderLatestCard() {
|
||||||
|
if (!sstvDom.liveLatest) return;
|
||||||
|
const latest = sstvHistory[sstvHistory.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = imageUrl(latest);
|
||||||
|
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||||
|
const state = latest.complete ? "complete" : "partial";
|
||||||
|
sstvDom.liveLatest.innerHTML = `
|
||||||
|
<div class="sat-latest-card">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||||
|
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||||
|
</div>
|
||||||
|
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||||
|
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||||
|
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||||
|
</a>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function filteredHistory() {
|
||||||
|
const text = filterText.trim().toLowerCase();
|
||||||
|
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
||||||
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
function renderHistoryTable() {
|
||||||
|
if (!sstvDom.historyList) return;
|
||||||
|
pruneHistory();
|
||||||
|
const rows = filteredHistory();
|
||||||
|
sstvDom.historyList.innerHTML = rows.map((image) => {
|
||||||
|
const url = imageUrl(image);
|
||||||
|
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||||
|
const lines = image.complete ? String(image.lines ?? 0) : `${image.lines ?? 0} (partial)`;
|
||||||
|
return `<div class="sat-history-row">
|
||||||
|
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||||
|
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||||
|
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||||
|
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||||
|
<span class="sat-col-link">${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>` : "--"}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
if (sstvDom.historyCount) {
|
||||||
|
sstvDom.historyCount.textContent = rows.length ? `${rows.length} picture${rows.length === 1 ? "" : "s"}` : "No pictures yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sstvDom.filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = sstvDom.filterInput?.value ?? "";
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
sstvDom.sortSelect?.addEventListener("change", () => {
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
function restoreHistory(entries) {
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
for (const entry of entries) onImage(entry);
|
||||||
|
}
|
||||||
|
function resetHistoryView() {
|
||||||
|
sstvHistory = [];
|
||||||
|
liveRows = 0;
|
||||||
|
liveMode = "";
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled) {
|
||||||
|
const button = sstvDom.toggleBtn;
|
||||||
|
if (!button) return;
|
||||||
|
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||||
|
button.setAttribute("aria-pressed", String(enabled));
|
||||||
|
button.classList.toggle("is-active", enabled);
|
||||||
|
};
|
||||||
|
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (sstvDom.toggleBtn) {
|
||||||
|
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_sstv_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_sstv_decode");
|
||||||
|
resetHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
renderLatestCard();
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv",
|
||||||
|
onMessage: onImage,
|
||||||
|
restore: restoreHistory,
|
||||||
|
prune: renderHistoryTable,
|
||||||
|
reset: resetHistoryView
|
||||||
|
});
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv_progress",
|
||||||
|
onMessage: onProgress
|
||||||
|
});
|
||||||
@@ -584,6 +584,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||||
<button class="sub-tab" data-subtab="sat">SAT</button>
|
<button class="sub-tab" data-subtab="sat">SAT</button>
|
||||||
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
||||||
|
<button class="sub-tab" data-subtab="sstv">SSTV</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-overview" class="sub-tab-panel">
|
<div id="subtab-overview" class="sub-tab-panel">
|
||||||
<div class="plugin-item" data-decoder="ais">
|
<div class="plugin-item" data-decoder="ais">
|
||||||
@@ -646,6 +647,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="plugin-item" data-decoder="sstv">
|
||||||
|
<strong>SSTV Decoder</strong>
|
||||||
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
|
Slow-Scan Television — pictures in Martin, Scottie, Robot, PD and Wraase modes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="plugin-item" data-decoder="wefax">
|
<div class="plugin-item" data-decoder="wefax">
|
||||||
<strong>WEFAX Decoder</strong>
|
<strong>WEFAX Decoder</strong>
|
||||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||||
@@ -653,6 +660,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtab-sstv" class="sub-tab-panel" style="display:none;">
|
||||||
|
<div class="ft8-controls">
|
||||||
|
<button id="sstv-decode-toggle-btn" type="button">Enable SSTV</button>
|
||||||
|
<small id="sstv-status" style="color:var(--text-muted);">Idle</small>
|
||||||
|
</div>
|
||||||
|
<!-- View selector -->
|
||||||
|
<div class="sat-view-bar">
|
||||||
|
<button id="sstv-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||||
|
<button id="sstv-view-history" class="sat-view-btn" type="button">History</button>
|
||||||
|
</div>
|
||||||
|
<!-- Live view -->
|
||||||
|
<div id="sstv-live-view">
|
||||||
|
<div style="margin:0 0 0.5rem;">
|
||||||
|
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
||||||
|
<strong>Slow-Scan Television</strong> — pictures sent as tones, in Martin,
|
||||||
|
Scottie, Robot, PD and Wraase modes. Tune a sideband or FM signal and enable the
|
||||||
|
decoder; the mode is read from the header the sender opens with.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sstv-live-container" style="display:none; margin:0.5rem 0;">
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
|
||||||
|
<strong>Receiving</strong>
|
||||||
|
<small id="sstv-live-info" style="color:var(--text-muted);"></small>
|
||||||
|
</div>
|
||||||
|
<canvas id="sstv-live-canvas" width="320" height="256"
|
||||||
|
style="width:100%; max-width:640px; image-rendering:pixelated; background:#111;"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="sstv-live-latest" style="margin-top:0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- History view -->
|
||||||
|
<div id="sstv-history-view" style="display:none;">
|
||||||
|
<div class="sat-history-controls">
|
||||||
|
<input id="sstv-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Martin, PD120)" />
|
||||||
|
<select id="sstv-sort" class="sat-sort-select">
|
||||||
|
<option value="newest">Newest first</option>
|
||||||
|
<option value="oldest">Oldest first</option>
|
||||||
|
</select>
|
||||||
|
<button id="sstv-clear-btn" type="button" style="font-size:0.8rem;">Clear All</button>
|
||||||
|
</div>
|
||||||
|
<div class="sat-history-header">
|
||||||
|
<span class="sat-col-time">Time</span>
|
||||||
|
<span class="sat-col-type">Mode</span>
|
||||||
|
<span class="sat-col-sat">Size</span>
|
||||||
|
<span class="sat-col-lines">Lines</span>
|
||||||
|
<span class="sat-col-link">Image</span>
|
||||||
|
</div>
|
||||||
|
<div id="sstv-history-list"></div>
|
||||||
|
<small id="sstv-history-count" style="color:var(--text-muted);font-size:0.75rem;">No pictures yet</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="rds-grid">
|
<div class="rds-grid">
|
||||||
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ await build({
|
|||||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||||
|
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export type RigRxStatus = { sig: number | null, };
|
|||||||
|
|
||||||
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
||||||
|
|
||||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
|
|||||||
/**
|
/**
|
||||||
* Per-virtual-channel RDS snapshots, when available.
|
* Per-virtual-channel RDS snapshots, when available.
|
||||||
*/
|
*/
|
||||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||||
|
|
||||||
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ declare global {
|
|||||||
updateFt8RfDisplay?(): void;
|
updateFt8RfDisplay?(): void;
|
||||||
clearSatPredictionDom?(): void;
|
clearSatPredictionDom?(): void;
|
||||||
syncWefaxToggle?(enabled: boolean): void;
|
syncWefaxToggle?(enabled: boolean): void;
|
||||||
|
syncSstvToggle?(enabled: boolean): void;
|
||||||
updateAisBar?(value?: number): void;
|
updateAisBar?(value?: number): void;
|
||||||
updateVdesBar?(value?: number): void;
|
updateVdesBar?(value?: number): void;
|
||||||
updateAprsBar?(value?: number): void;
|
updateAprsBar?(value?: number): void;
|
||||||
@@ -858,7 +859,7 @@ function currentDecodeHistoryRetentionMs() {
|
|||||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||||
|
|
||||||
window.applyDecodeHistoryRetention = function() {
|
window.applyDecodeHistoryRetention = function() {
|
||||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||||
window.trxPluginRuntime.prune(decoder);
|
window.trxPluginRuntime.prune(decoder);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3712,7 +3713,11 @@ function render(update: AppUpdate) {
|
|||||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||||
}
|
}
|
||||||
// WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles).
|
// Image decoders own their own toggle buttons; keep them in step with the
|
||||||
|
// rig state as well as with the click that set it.
|
||||||
|
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||||
|
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||||
|
}
|
||||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||||
}
|
}
|
||||||
@@ -6567,9 +6572,15 @@ function updateDecodeStatus(text: string) {
|
|||||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Picture decoders produce one message per image and a stream of progress
|
||||||
|
// updates; neither is a spot, and counting them would swamp the statistics.
|
||||||
|
const IMAGE_DECODE_KINDS = new Set([
|
||||||
|
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||||
|
]);
|
||||||
|
|
||||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
@@ -6619,7 +6630,7 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
|
|||||||
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
// Record statistics for restored history messages.
|
// Record statistics for restored history messages.
|
||||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
|
||||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||||
interface DecodeState { offset: number }
|
interface DecodeState { offset: number }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|||||||
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||||
"digital-modes": [
|
"digital-modes": [
|
||||||
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
"/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-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// sstv.ts — SSTV decoder panel.
|
||||||
|
//
|
||||||
|
// Live view: decoder state, a canvas the picture fills row by row as it
|
||||||
|
// arrives, and a card for the last one received. History view: a filterable
|
||||||
|
// table of received pictures with thumbnails.
|
||||||
|
//
|
||||||
|
// Watching a picture build up is most of the appeal of the mode, so rows are
|
||||||
|
// painted as they arrive rather than waiting for the frame to finish — a
|
||||||
|
// transmission takes between 36 seconds and two minutes.
|
||||||
|
|
||||||
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface SstvImage {
|
||||||
|
ts_ms?: number;
|
||||||
|
vis?: number;
|
||||||
|
mode?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
lines?: number;
|
||||||
|
complete?: boolean;
|
||||||
|
path?: string;
|
||||||
|
_tsMs?: number;
|
||||||
|
_ts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SstvProgress {
|
||||||
|
state?: string;
|
||||||
|
mode?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
line?: number;
|
||||||
|
line_data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SstvBridge {
|
||||||
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
|
syncSstvToggle?: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sstvWindow = window as unknown as SstvBridge & PluginRuntimeWindow;
|
||||||
|
|
||||||
|
const sstvDom = {
|
||||||
|
status: document.getElementById("sstv-status"),
|
||||||
|
liveView: document.getElementById("sstv-live-view"),
|
||||||
|
historyView: document.getElementById("sstv-history-view"),
|
||||||
|
liveContainer: document.getElementById("sstv-live-container"),
|
||||||
|
liveInfo: document.getElementById("sstv-live-info"),
|
||||||
|
liveCanvas: document.getElementById("sstv-live-canvas") as HTMLCanvasElement | null,
|
||||||
|
liveLatest: document.getElementById("sstv-live-latest"),
|
||||||
|
historyList: document.getElementById("sstv-history-list"),
|
||||||
|
historyCount: document.getElementById("sstv-history-count"),
|
||||||
|
filterInput: document.getElementById("sstv-filter") as HTMLInputElement | null,
|
||||||
|
sortSelect: document.getElementById("sstv-sort") as HTMLSelectElement | null,
|
||||||
|
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("sstv-view-history"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const SSTV_MAX_IMAGES = 100;
|
||||||
|
|
||||||
|
let sstvHistory: SstvImage[] = [];
|
||||||
|
let liveCtx: CanvasRenderingContext2D | null = null;
|
||||||
|
let liveMode = "";
|
||||||
|
let liveHeight = 0;
|
||||||
|
let liveRows = 0;
|
||||||
|
let activeView: "live" | "history" = "live";
|
||||||
|
let filterText = "";
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function retentionMs(): number {
|
||||||
|
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHistory() {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown): string {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleUi(key: string, job: () => void): void {
|
||||||
|
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The URL the server serves a saved picture from, given its stored path. */
|
||||||
|
function imageUrl(image: SstvImage): string | null {
|
||||||
|
if (!image.path) return null;
|
||||||
|
const filename = image.path.split(/[\\/]/).pop();
|
||||||
|
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(data: string): Uint8Array {
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function switchView(view: "live" | "history"): void {
|
||||||
|
activeView = view;
|
||||||
|
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||||
|
button?.classList.remove("sat-view-active");
|
||||||
|
}
|
||||||
|
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||||
|
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderHistoryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvDom.viewLiveBtn?.addEventListener("click", () => { switchView("live"); });
|
||||||
|
sstvDom.viewHistoryBtn?.addEventListener("click", () => { switchView("history"); });
|
||||||
|
|
||||||
|
// ── Live canvas ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Start a new picture: size the canvas to the mode and clear it. */
|
||||||
|
function beginPicture(mode: string, width: number, height: number): void {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!canvas || width <= 0 || height <= 0) return;
|
||||||
|
liveMode = mode;
|
||||||
|
liveHeight = height;
|
||||||
|
liveRows = 0;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
liveCtx = canvas.getContext("2d");
|
||||||
|
if (!liveCtx) return;
|
||||||
|
// Mid-grey, not black: the rows below the one arriving have not been
|
||||||
|
// received, which is a different thing from having been received as black.
|
||||||
|
liveCtx.fillStyle = "#606060";
|
||||||
|
liveCtx.fillRect(0, 0, width, height);
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||||
|
updateLiveInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveInfo(): void {
|
||||||
|
if (!sstvDom.liveInfo) return;
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||||
|
sstvDom.liveInfo.textContent = liveMode
|
||||||
|
? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint one row of RGB triples at its own line number. */
|
||||||
|
function paintRow(line: number, rgb: Uint8Array): void {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!liveCtx || !canvas) return;
|
||||||
|
const width = canvas.width;
|
||||||
|
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||||
|
const row = liveCtx.createImageData(width, 1);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||||
|
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||||
|
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||||
|
row.data[x * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
liveCtx.putImageData(row, 0, line);
|
||||||
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server messages ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onProgress(msg: SstvProgress): void {
|
||||||
|
if (msg.state) {
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
|
// A state update carries the geometry; a row update carries only the row.
|
||||||
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||||
|
const line = msg.line;
|
||||||
|
const rgb = decodeBase64(msg.line_data);
|
||||||
|
scheduleUi(`sstv-row-${line}`, () => {
|
||||||
|
paintRow(line, rgb);
|
||||||
|
updateLiveInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImage(msg: SstvImage): void {
|
||||||
|
const image: SstvImage = { ...msg };
|
||||||
|
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||||
|
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||||
|
sstvHistory.push(image);
|
||||||
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
|
pruneHistory();
|
||||||
|
|
||||||
|
if (sstvDom.status) {
|
||||||
|
sstvDom.status.textContent = image.complete
|
||||||
|
? `Received ${image.mode ?? "picture"}`
|
||||||
|
: `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
|
}
|
||||||
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderLatestCard(): void {
|
||||||
|
if (!sstvDom.liveLatest) return;
|
||||||
|
const latest = sstvHistory[sstvHistory.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = imageUrl(latest);
|
||||||
|
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||||
|
const state = latest.complete ? "complete" : "partial";
|
||||||
|
sstvDom.liveLatest.innerHTML = `
|
||||||
|
<div class="sat-latest-card">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||||
|
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||||
|
</div>
|
||||||
|
${url
|
||||||
|
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||||
|
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||||
|
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||||
|
</a>`
|
||||||
|
: ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredHistory(): SstvImage[] {
|
||||||
|
const text = filterText.trim().toLowerCase();
|
||||||
|
const matching = text
|
||||||
|
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||||
|
: sstvHistory.slice();
|
||||||
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryTable(): void {
|
||||||
|
if (!sstvDom.historyList) return;
|
||||||
|
pruneHistory();
|
||||||
|
const rows = filteredHistory();
|
||||||
|
sstvDom.historyList.innerHTML = rows
|
||||||
|
.map((image) => {
|
||||||
|
const url = imageUrl(image);
|
||||||
|
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||||
|
const lines = image.complete
|
||||||
|
? String(image.lines ?? 0)
|
||||||
|
: `${image.lines ?? 0} (partial)`;
|
||||||
|
return `<div class="sat-history-row">
|
||||||
|
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||||
|
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||||
|
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||||
|
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||||
|
<span class="sat-col-link">${url
|
||||||
|
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>`
|
||||||
|
: "--"}</span>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
if (sstvDom.historyCount) {
|
||||||
|
sstvDom.historyCount.textContent = rows.length
|
||||||
|
? `${rows.length} picture${rows.length === 1 ? "" : "s"}`
|
||||||
|
: "No pictures yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sstvDom.filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = sstvDom.filterInput?.value ?? "";
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
sstvDom.sortSelect?.addEventListener("change", () => { renderHistoryTable(); });
|
||||||
|
|
||||||
|
// ── Decoder history plumbing ────────────────────────────────────────
|
||||||
|
|
||||||
|
function restoreHistory(entries: unknown[]): void {
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
for (const entry of entries) onImage(entry as SstvImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHistoryView(): void {
|
||||||
|
sstvHistory = [];
|
||||||
|
liveRows = 0;
|
||||||
|
liveMode = "";
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled: boolean) {
|
||||||
|
const button = sstvDom.toggleBtn as HTMLButtonElement | null;
|
||||||
|
if (!button) return;
|
||||||
|
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||||
|
button.setAttribute("aria-pressed", String(enabled));
|
||||||
|
button.classList.toggle("is-active", enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (sstvDom.toggleBtn) {
|
||||||
|
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_sstv_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_sstv_decode");
|
||||||
|
resetHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLatestCard();
|
||||||
|
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv",
|
||||||
|
onMessage: onImage as (msg: unknown) => void,
|
||||||
|
restore: restoreHistory,
|
||||||
|
prune: renderHistoryTable,
|
||||||
|
reset: resetHistoryView,
|
||||||
|
});
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv_progress",
|
||||||
|
onMessage: onProgress as (msg: unknown) => void,
|
||||||
|
});
|
||||||
@@ -541,7 +541,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
select.setAttribute("aria-label", "Decoder view");
|
select.setAttribute("aria-label", "Decoder view");
|
||||||
const groups: Array<[string, string[]]> = [
|
const groups: Array<[string, string[]]> = [
|
||||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]],
|
||||||
];
|
];
|
||||||
groups.forEach(([label, ids]) => {
|
groups.forEach(([label, ids]) => {
|
||||||
const group = document.createElement("optgroup");
|
const group = document.createElement("optgroup");
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// 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();
|
||||||
|
|
||||||
|
runtime.dispatch("sstv", {
|
||||||
|
ts_ms: Date.UTC(2026, 7, 5, 12, 0, 0),
|
||||||
|
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: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
|
||||||
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
|
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
|
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
|
||||||
|
{ id: "sstv", label: "SSTV", activation: "toggle", active_modes: ["USB", "FM"] },
|
||||||
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
|
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||||
@@ -104,7 +105,7 @@ function encodeCbor(value) {
|
|||||||
return Buffer.concat(chunks);
|
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) {
|
function assetPath(urlPath) {
|
||||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||||
@@ -203,6 +204,7 @@ export async function startWebFixture({
|
|||||||
wspr_decode_enabled: false,
|
wspr_decode_enabled: false,
|
||||||
lrpt_decode_enabled: false,
|
lrpt_decode_enabled: false,
|
||||||
wefax_decode_enabled: false,
|
wefax_decode_enabled: false,
|
||||||
|
sstv_decode_enabled: false,
|
||||||
recorder_enabled: false,
|
recorder_enabled: false,
|
||||||
clients: 1,
|
clients: 1,
|
||||||
rigctl_clients: 0,
|
rigctl_clients: 0,
|
||||||
|
|||||||
@@ -175,21 +175,34 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
|
|||||||
static_asset_response(&req, content_type, entry)
|
static_asset_response(&req, content_type, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve a received SSTV picture out of the local cache.
|
||||||
|
#[get("/sstv-images/{filename}")]
|
||||||
|
pub(crate) async fn sstv_image(path: web::Path<String>) -> impl Responder {
|
||||||
|
cached_png("sstv", &path.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/images/{filename}")]
|
#[get("/images/{filename}")]
|
||||||
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
pub(crate) async fn wefax_image(path: web::Path<String>) -> impl Responder {
|
||||||
let filename = path.into_inner();
|
cached_png("wefax", &path.into_inner())
|
||||||
// Reject path traversal attempts.
|
}
|
||||||
|
|
||||||
|
/// Read a PNG out of one of the decoder cache directories.
|
||||||
|
///
|
||||||
|
/// The file name comes from a client, so it is checked rather than trusted: no
|
||||||
|
/// separators, no parent references, and a .png suffix. Everything a decoder
|
||||||
|
/// writes is named that way, and nothing else in the cache is servable.
|
||||||
|
fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
|
||||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
return HttpResponse::BadRequest().body("invalid filename");
|
return HttpResponse::BadRequest().body("invalid filename");
|
||||||
}
|
}
|
||||||
if !filename.ends_with(".png") {
|
if !filename.ends_with(".png") {
|
||||||
return HttpResponse::BadRequest().body("only .png files are accessible");
|
return HttpResponse::BadRequest().body("only .png files are accessible");
|
||||||
}
|
}
|
||||||
let dir = dirs::cache_dir()
|
let file_path = dirs::cache_dir()
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
.join("trx-rs")
|
.join("trx-rs")
|
||||||
.join("wefax");
|
.join(decoder)
|
||||||
let file_path = dir.join(&filename);
|
.join(filename);
|
||||||
match std::fs::read(&file_path) {
|
match std::fs::read(&file_path) {
|
||||||
Ok(data) => HttpResponse::Ok()
|
Ok(data) => HttpResponse::Ok()
|
||||||
.insert_header((header::CONTENT_TYPE, "image/png"))
|
.insert_header((header::CONTENT_TYPE, "image/png"))
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ struct DecodeHistoryPayload {
|
|||||||
ft2: Vec<trx_core::decode::Ft8Message>,
|
ft2: Vec<trx_core::decode::Ft8Message>,
|
||||||
wspr: Vec<trx_core::decode::WsprMessage>,
|
wspr: Vec<trx_core::decode::WsprMessage>,
|
||||||
wefax: Vec<trx_core::decode::WefaxMessage>,
|
wefax: Vec<trx_core::decode::WefaxMessage>,
|
||||||
|
sstv: Vec<trx_core::decode::SstvMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecodeHistoryPayload {
|
impl DecodeHistoryPayload {
|
||||||
@@ -74,6 +75,7 @@ impl DecodeHistoryPayload {
|
|||||||
+ self.ft2.len()
|
+ self.ft2.len()
|
||||||
+ self.wspr.len()
|
+ self.wspr.len()
|
||||||
+ self.wefax.len()
|
+ self.wefax.len()
|
||||||
|
+ self.sstv.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ fn collect_decode_history(
|
|||||||
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
|
||||||
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
|
||||||
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
|
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
|
||||||
|
sstv: crate::server::audio::snapshot_sstv_history(context, rig_filter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,10 +454,40 @@ pub async fn toggle_wefax_decode(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/toggle_sstv_decode")]
|
||||||
|
pub async fn toggle_sstv_decode(
|
||||||
|
query: web::Query<RemoteQuery>,
|
||||||
|
state: web::Data<watch::Receiver<RigState>>,
|
||||||
|
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||||
|
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let q = query.into_inner();
|
||||||
|
let rig_state = resolve_rig_state(q.remote.as_deref(), &context, state.get_ref());
|
||||||
|
send_command(
|
||||||
|
&rig_tx,
|
||||||
|
RigCommand::SetSstvDecodeEnabled(!rig_state.decoders.sstv_decode_enabled),
|
||||||
|
q.remote,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Decoder clear endpoints
|
// Decoder clear endpoints
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[post("/clear_sstv_decode")]
|
||||||
|
pub async fn clear_sstv_decode(
|
||||||
|
query: web::Query<RemoteQuery>,
|
||||||
|
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
send_command(
|
||||||
|
&rig_tx,
|
||||||
|
RigCommand::ResetSstvDecoder,
|
||||||
|
query.into_inner().remote,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/clear_wefax_decode")]
|
#[post("/clear_wefax_decode")]
|
||||||
pub async fn clear_wefax_decode(
|
pub async fn clear_wefax_decode(
|
||||||
query: web::Query<RemoteQuery>,
|
query: web::Query<RemoteQuery>,
|
||||||
|
|||||||
@@ -591,6 +591,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(decoder::toggle_wspr_decode)
|
.service(decoder::toggle_wspr_decode)
|
||||||
.service(decoder::toggle_lrpt_decode)
|
.service(decoder::toggle_lrpt_decode)
|
||||||
.service(decoder::toggle_wefax_decode)
|
.service(decoder::toggle_wefax_decode)
|
||||||
|
.service(decoder::toggle_sstv_decode)
|
||||||
.service(decoder::clear_ais_decode)
|
.service(decoder::clear_ais_decode)
|
||||||
.service(decoder::clear_vdes_decode)
|
.service(decoder::clear_vdes_decode)
|
||||||
.service(decoder::clear_aprs_decode)
|
.service(decoder::clear_aprs_decode)
|
||||||
@@ -602,6 +603,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(decoder::clear_wspr_decode)
|
.service(decoder::clear_wspr_decode)
|
||||||
.service(decoder::clear_lrpt_decode)
|
.service(decoder::clear_lrpt_decode)
|
||||||
.service(decoder::clear_wefax_decode)
|
.service(decoder::clear_wefax_decode)
|
||||||
|
.service(decoder::clear_sstv_decode)
|
||||||
// Bookmark CRUD
|
// Bookmark CRUD
|
||||||
.service(bookmarks::list_bookmarks)
|
.service(bookmarks::list_bookmarks)
|
||||||
.service(bookmarks::create_bookmark)
|
.service(bookmarks::create_bookmark)
|
||||||
@@ -645,6 +647,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(assets::style_css)
|
.service(assets::style_css)
|
||||||
.service(assets::themes_css)
|
.service(assets::themes_css)
|
||||||
.service(assets::wefax_image)
|
.service(assets::wefax_image)
|
||||||
|
.service(assets::sstv_image)
|
||||||
.service(assets::bandplan_json)
|
.service(assets::bandplan_json)
|
||||||
// Vendored DSEG14 Classic font
|
// Vendored DSEG14 Classic font
|
||||||
.service(assets::dseg14_classic_woff2)
|
.service(assets::dseg14_classic_woff2)
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ use tracing::warn;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||||
WsprMessage,
|
WefaxMessage, WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_frontend::FrontendRuntimeContext;
|
use trx_frontend::FrontendRuntimeContext;
|
||||||
|
|
||||||
@@ -335,6 +335,43 @@ fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store a received picture, saving the PNG the server sent into the local
|
||||||
|
/// cache so `/images/` can serve it back.
|
||||||
|
fn record_sstv(context: &FrontendRuntimeContext, mut msg: SstvMessage) {
|
||||||
|
if let Some(ref data) = msg.png_data {
|
||||||
|
if let Some(ref path) = msg.path {
|
||||||
|
if let Some(filename) = std::path::Path::new(path).file_name() {
|
||||||
|
let dir = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
|
.join("trx-rs")
|
||||||
|
.join("sstv");
|
||||||
|
if std::fs::create_dir_all(&dir).is_ok() {
|
||||||
|
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
|
||||||
|
let local_path = dir.join(filename);
|
||||||
|
if let Err(e) = std::fs::write(&local_path, &bytes) {
|
||||||
|
tracing::warn!("SSTV: failed to save local image: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The picture itself is on disk now; keeping a megabyte of base64 per
|
||||||
|
// entry in memory is what the history does not need.
|
||||||
|
msg.png_data = None;
|
||||||
|
|
||||||
|
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
|
||||||
|
let mut history = context
|
||||||
|
.decode_history
|
||||||
|
.sstv
|
||||||
|
.lock()
|
||||||
|
.expect("sstv history mutex poisoned");
|
||||||
|
history.push_back((Instant::now(), rig_id, msg));
|
||||||
|
while history.len() > 100 {
|
||||||
|
history.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if the entry's rig_id matches the optional filter.
|
/// Returns `true` if the entry's rig_id matches the optional filter.
|
||||||
/// `None` filter means "all rigs".
|
/// `None` filter means "all rigs".
|
||||||
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
|
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
|
||||||
@@ -526,6 +563,31 @@ pub fn snapshot_wefax_history(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_sstv_history(
|
||||||
|
context: &FrontendRuntimeContext,
|
||||||
|
rig_filter: Option<&str>,
|
||||||
|
) -> Vec<SstvMessage> {
|
||||||
|
let history = context
|
||||||
|
.decode_history
|
||||||
|
.sstv
|
||||||
|
.lock()
|
||||||
|
.expect("sstv history mutex poisoned");
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
|
||||||
|
.map(|(_, _, msg)| msg.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_sstv_history(context: &FrontendRuntimeContext) {
|
||||||
|
let mut history = context
|
||||||
|
.decode_history
|
||||||
|
.sstv
|
||||||
|
.lock()
|
||||||
|
.expect("sstv history mutex poisoned");
|
||||||
|
history.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
|
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
|
||||||
let mut history = context
|
let mut history = context
|
||||||
.decode_history
|
.decode_history
|
||||||
@@ -650,6 +712,9 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
|||||||
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
|
||||||
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
|
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
|
||||||
DecodedMessage::WefaxProgress(_) => {}
|
DecodedMessage::WefaxProgress(_) => {}
|
||||||
|
DecodedMessage::Sstv(msg) => record_sstv(&context, msg),
|
||||||
|
// Progress is for watching, not for keeping.
|
||||||
|
DecodedMessage::SstvProgress(_) => {}
|
||||||
DecodedMessage::LrptImage(_) => {}
|
DecodedMessage::LrptImage(_) => {}
|
||||||
DecodedMessage::LrptProgress(_) => {}
|
DecodedMessage::LrptProgress(_) => {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
|
|||||||
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
|
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
|
||||||
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
|
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
|
||||||
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
|
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
|
||||||
|
/// Server → client: SSTV received picture (JSON `DecodedMessage::Sstv`).
|
||||||
|
pub const AUDIO_MSG_SSTV_DECODE: u8 = 0x1B;
|
||||||
|
/// Server → client: SSTV decode progress, one line at a time
|
||||||
|
/// (JSON `DecodedMessage::SstvProgress`).
|
||||||
|
pub const AUDIO_MSG_SSTV_PROGRESS: u8 = 0x1C;
|
||||||
|
|
||||||
/// Maximum payload size for normal messages (1 MB).
|
/// Maximum payload size for normal messages (1 MB).
|
||||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub enum DecodedMessage {
|
|||||||
Wefax(WefaxMessage),
|
Wefax(WefaxMessage),
|
||||||
#[serde(rename = "wefax_progress")]
|
#[serde(rename = "wefax_progress")]
|
||||||
WefaxProgress(WefaxProgress),
|
WefaxProgress(WefaxProgress),
|
||||||
|
#[serde(rename = "sstv")]
|
||||||
|
Sstv(SstvMessage),
|
||||||
|
#[serde(rename = "sstv_progress")]
|
||||||
|
SstvProgress(SstvProgress),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DecodedMessage {
|
impl DecodedMessage {
|
||||||
@@ -52,6 +56,8 @@ impl DecodedMessage {
|
|||||||
Self::LrptProgress(m) => m.rig_id = Some(id),
|
Self::LrptProgress(m) => m.rig_id = Some(id),
|
||||||
Self::Wefax(m) => m.rig_id = Some(id),
|
Self::Wefax(m) => m.rig_id = Some(id),
|
||||||
Self::WefaxProgress(m) => m.rig_id = Some(id),
|
Self::WefaxProgress(m) => m.rig_id = Some(id),
|
||||||
|
Self::Sstv(m) => m.rig_id = Some(id),
|
||||||
|
Self::SstvProgress(m) => m.rig_id = Some(id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,8 @@ impl DecodedMessage {
|
|||||||
Self::LrptProgress(m) => m.rig_id.as_deref(),
|
Self::LrptProgress(m) => m.rig_id.as_deref(),
|
||||||
Self::Wefax(m) => m.rig_id.as_deref(),
|
Self::Wefax(m) => m.rig_id.as_deref(),
|
||||||
Self::WefaxProgress(m) => m.rig_id.as_deref(),
|
Self::WefaxProgress(m) => m.rig_id.as_deref(),
|
||||||
|
Self::Sstv(m) => m.rig_id.as_deref(),
|
||||||
|
Self::SstvProgress(m) => m.rig_id.as_deref(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,3 +327,52 @@ pub struct WefaxProgress {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A received SSTV picture, complete or cut short.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SstvMessage {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rig_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ts_ms: Option<i64>,
|
||||||
|
/// VIS code the transmission announced itself with.
|
||||||
|
pub vis: u8,
|
||||||
|
/// Mode name, e.g. "Martin M1".
|
||||||
|
pub mode: String,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Image lines actually received, which is the height only if the whole
|
||||||
|
/// frame arrived.
|
||||||
|
pub lines: u16,
|
||||||
|
/// Whether reception reached the bottom of the frame.
|
||||||
|
pub complete: bool,
|
||||||
|
/// Filesystem path to the saved PNG, when one was written.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// Base64-encoded PNG for the trip to a client. Populated when sending and
|
||||||
|
/// stripped before the message is stored in history, which would otherwise
|
||||||
|
/// hold a megabyte per picture.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub png_data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picture arriving, emitted per line so it can be watched.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SstvProgress {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rig_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ts_ms: Option<i64>,
|
||||||
|
pub vis: u8,
|
||||||
|
pub mode: String,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Index of the line this update carries.
|
||||||
|
pub line: u16,
|
||||||
|
/// Base64-encoded RGB triples for that one line.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub line_data: Option<String>,
|
||||||
|
/// Decoder state for display, e.g. "Receiving Martin M1".
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub state: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub enum RigCommand {
|
|||||||
SetWsprDecodeEnabled(bool),
|
SetWsprDecodeEnabled(bool),
|
||||||
SetLrptDecodeEnabled(bool),
|
SetLrptDecodeEnabled(bool),
|
||||||
SetWefaxDecodeEnabled(bool),
|
SetWefaxDecodeEnabled(bool),
|
||||||
|
SetSstvDecodeEnabled(bool),
|
||||||
ResetAprsDecoder,
|
ResetAprsDecoder,
|
||||||
ResetHfAprsDecoder,
|
ResetHfAprsDecoder,
|
||||||
ResetCwDecoder,
|
ResetCwDecoder,
|
||||||
@@ -42,6 +43,7 @@ pub enum RigCommand {
|
|||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
ResetLrptDecoder,
|
ResetLrptDecoder,
|
||||||
ResetWefaxDecoder,
|
ResetWefaxDecoder,
|
||||||
|
ResetSstvDecoder,
|
||||||
SetBandwidth(u32),
|
SetBandwidth(u32),
|
||||||
SetSdrGain(f64),
|
SetSdrGain(f64),
|
||||||
SetSdrLnaGain(f64),
|
SetSdrLnaGain(f64),
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::ResetLrptDecoder
|
| RigCommand::ResetLrptDecoder
|
||||||
| RigCommand::SetWefaxDecodeEnabled(_)
|
| RigCommand::SetWefaxDecodeEnabled(_)
|
||||||
| RigCommand::ResetWefaxDecoder
|
| RigCommand::ResetWefaxDecoder
|
||||||
|
| RigCommand::SetSstvDecodeEnabled(_)
|
||||||
|
| RigCommand::ResetSstvDecoder
|
||||||
| RigCommand::SetBandwidth(_)
|
| RigCommand::SetBandwidth(_)
|
||||||
| RigCommand::SetSdrGain(_)
|
| RigCommand::SetSdrGain(_)
|
||||||
| RigCommand::SetSdrLnaGain(_)
|
| RigCommand::SetSdrLnaGain(_)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ pub struct DecoderConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub wefax_decode_enabled: bool,
|
pub wefax_decode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub sstv_decode_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub recorder_enabled: bool,
|
pub recorder_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ pub struct DecoderResetSeqs {
|
|||||||
pub lrpt_decode_reset_seq: u64,
|
pub lrpt_decode_reset_seq: u64,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub wefax_decode_reset_seq: u64,
|
pub wefax_decode_reset_seq: u64,
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub sstv_decode_reset_seq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple transceiver state representation held by the rig task.
|
/// Simple transceiver state representation held by the rig task.
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
|
|||||||
background_decode: false,
|
background_decode: false,
|
||||||
bookmark_selectable: true,
|
bookmark_selectable: true,
|
||||||
},
|
},
|
||||||
|
DecoderDescriptor {
|
||||||
|
id: "sstv",
|
||||||
|
label: "SSTV",
|
||||||
|
activation: DecoderActivation::Toggle,
|
||||||
|
// SSTV is sent on sideband on HF and on FM simplex on VHF, and the
|
||||||
|
// decoder cares only about the audio it is handed.
|
||||||
|
active_modes: &["USB", "LSB", "FM", "AM", "DIG"],
|
||||||
|
background_decode: false,
|
||||||
|
bookmark_selectable: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ define_command_mapping! {
|
|||||||
ResetFt2Decoder <=> ResetFt2Decoder,
|
ResetFt2Decoder <=> ResetFt2Decoder,
|
||||||
ResetWsprDecoder <=> ResetWsprDecoder,
|
ResetWsprDecoder <=> ResetWsprDecoder,
|
||||||
ResetLrptDecoder <=> ResetLrptDecoder,
|
ResetLrptDecoder <=> ResetLrptDecoder,
|
||||||
ResetWefaxDecoder <=> ResetWefaxDecoder;
|
ResetWefaxDecoder <=> ResetWefaxDecoder,
|
||||||
|
ResetSstvDecoder <=> ResetSstvDecoder;
|
||||||
|
|
||||||
// ── Single-field struct <=> tuple ────────────────────────────────
|
// ── Single-field struct <=> tuple ────────────────────────────────
|
||||||
field:
|
field:
|
||||||
@@ -140,6 +141,7 @@ define_command_mapping! {
|
|||||||
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
|
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
|
||||||
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
|
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
|
||||||
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
|
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
|
||||||
|
SetSstvDecodeEnabled { enabled } <=> SetSstvDecodeEnabled,
|
||||||
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
|
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
|
||||||
SetSdrGain { gain_db } <=> SetSdrGain,
|
SetSdrGain { gain_db } <=> SetSdrGain,
|
||||||
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ pub enum ClientCommand {
|
|||||||
SetWefaxDecodeEnabled {
|
SetWefaxDecodeEnabled {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
},
|
},
|
||||||
|
SetSstvDecodeEnabled {
|
||||||
|
enabled: bool,
|
||||||
|
},
|
||||||
ResetAprsDecoder,
|
ResetAprsDecoder,
|
||||||
ResetHfAprsDecoder,
|
ResetHfAprsDecoder,
|
||||||
ResetCwDecoder,
|
ResetCwDecoder,
|
||||||
@@ -82,6 +85,7 @@ pub enum ClientCommand {
|
|||||||
ResetWsprDecoder,
|
ResetWsprDecoder,
|
||||||
ResetLrptDecoder,
|
ResetLrptDecoder,
|
||||||
ResetWefaxDecoder,
|
ResetWefaxDecoder,
|
||||||
|
ResetSstvDecoder,
|
||||||
SetBandwidth {
|
SetBandwidth {
|
||||||
bandwidth_hz: u32,
|
bandwidth_hz: u32,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ trx-cw = { path = "../decoders/trx-cw" }
|
|||||||
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
||||||
trx-ftx = { path = "../decoders/trx-ftx" }
|
trx-ftx = { path = "../decoders/trx-ftx" }
|
||||||
trx-wefax = { path = "../decoders/trx-wefax" }
|
trx-wefax = { path = "../decoders/trx-wefax" }
|
||||||
|
trx-sstv = { path = "../decoders/trx-sstv" }
|
||||||
trx-wspr = { path = "../decoders/trx-wspr" }
|
trx-wspr = { path = "../decoders/trx-wspr" }
|
||||||
trx-wxsat = { path = "../decoders/trx-wxsat" }
|
trx-wxsat = { path = "../decoders/trx-wxsat" }
|
||||||
trx-protocol = { path = "../trx-protocol" }
|
trx-protocol = { path = "../trx-protocol" }
|
||||||
|
|||||||
+252
-4
@@ -30,10 +30,11 @@ use trx_core::audio::{
|
|||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
|
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_SSTV_DECODE, AUDIO_MSG_SSTV_PROGRESS,
|
||||||
AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ,
|
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW,
|
||||||
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
|
AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE,
|
||||||
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE,
|
||||||
|
AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
|
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
|
||||||
@@ -292,6 +293,11 @@ fn build_history_blob(histories: &DecoderHistories) -> (Vec<u8>, usize) {
|
|||||||
DecodedMessage::Wefax,
|
DecodedMessage::Wefax,
|
||||||
AUDIO_MSG_WEFAX_DECODE
|
AUDIO_MSG_WEFAX_DECODE
|
||||||
);
|
);
|
||||||
|
push_history!(
|
||||||
|
histories.snapshot_sstv_history(),
|
||||||
|
DecodedMessage::Sstv,
|
||||||
|
AUDIO_MSG_SSTV_DECODE
|
||||||
|
);
|
||||||
|
|
||||||
(blob, count)
|
(blob, count)
|
||||||
}
|
}
|
||||||
@@ -2444,6 +2450,244 @@ pub async fn run_wefax_decoder(
|
|||||||
info!("WEFAX decoder stopped");
|
info!("WEFAX decoder stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSTV decoder task
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Turn a decoded picture into the message clients receive, saving the PNG on
|
||||||
|
/// the way. The picture travels as base64 so a client on another machine has
|
||||||
|
/// it without a second request; the server keeps its own copy on disk.
|
||||||
|
fn sstv_message(
|
||||||
|
image: &trx_sstv::SstvImage,
|
||||||
|
output_dir: &std::path::Path,
|
||||||
|
freq_hz: u64,
|
||||||
|
) -> DecodedMessage {
|
||||||
|
let path = match image.save_png(output_dir, freq_hz) {
|
||||||
|
Ok(path) => Some(path.to_string_lossy().into_owned()),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("SSTV: failed to save image: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let png_data = image.to_png_base64().ok();
|
||||||
|
DecodedMessage::Sstv(trx_core::decode::SstvMessage {
|
||||||
|
rig_id: None,
|
||||||
|
ts_ms: Some(image.started_ms),
|
||||||
|
vis: image.vis,
|
||||||
|
mode: image.mode.to_string(),
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
lines: image.lines,
|
||||||
|
complete: image.complete,
|
||||||
|
path,
|
||||||
|
png_data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the SSTV decoder task. Processes PCM when enabled and the rig mode is
|
||||||
|
/// one SSTV is sent in.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn run_sstv_decoder(
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
|
||||||
|
mut state_rx: watch::Receiver<RigState>,
|
||||||
|
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||||
|
histories: Arc<DecoderHistories>,
|
||||||
|
output_dir: std::path::PathBuf,
|
||||||
|
) {
|
||||||
|
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||||
|
|
||||||
|
info!("SSTV decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
|
|
||||||
|
let mut decoder = SstvDecoder::new(sample_rate, SstvConfig::default());
|
||||||
|
let mut was_active = false;
|
||||||
|
let mut last_reset_seq: u64 = 0;
|
||||||
|
|
||||||
|
let is_sstv_mode = |mode: &RigMode| {
|
||||||
|
matches!(
|
||||||
|
mode,
|
||||||
|
RigMode::USB | RigMode::LSB | RigMode::FM | RigMode::AM | RigMode::DIG
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset returns any picture in progress rather than discarding it: a
|
||||||
|
// half-received frame is still worth keeping.
|
||||||
|
macro_rules! flush {
|
||||||
|
($decoder:expr) => {
|
||||||
|
for event in $decoder.reset() {
|
||||||
|
if let SstvEvent::Complete(image) = event {
|
||||||
|
// Read the dial now rather than caching it: the picture is
|
||||||
|
// named for where it was received.
|
||||||
|
let freq_hz = state_rx.borrow().status.freq.hz;
|
||||||
|
let msg = sstv_message(&image, &output_dir, freq_hz);
|
||||||
|
if let DecodedMessage::Sstv(ref stored) = msg {
|
||||||
|
histories.record_sstv_message(stored.clone());
|
||||||
|
}
|
||||||
|
let _ = decode_tx.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = state_rx.borrow().decoders.sstv_decode_enabled
|
||||||
|
&& is_sstv_mode(&state_rx.borrow().status.mode);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !active {
|
||||||
|
match state_rx.changed().await {
|
||||||
|
Ok(()) => {
|
||||||
|
let state = state_rx.borrow();
|
||||||
|
active = state.decoders.sstv_decode_enabled && is_sstv_mode(&state.status.mode);
|
||||||
|
if active {
|
||||||
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
recv = pcm_rx.recv() => {
|
||||||
|
match recv {
|
||||||
|
Ok(frame) => {
|
||||||
|
let (process_enabled, reset_seq) = {
|
||||||
|
let state = state_rx.borrow();
|
||||||
|
(
|
||||||
|
state.decoders.sstv_decode_enabled
|
||||||
|
&& is_sstv_mode(&state.status.mode),
|
||||||
|
state.reset_seqs.sstv_decode_reset_seq,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if reset_seq != last_reset_seq {
|
||||||
|
last_reset_seq = reset_seq;
|
||||||
|
flush!(decoder);
|
||||||
|
info!("SSTV decoder reset (seq={})", last_reset_seq);
|
||||||
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !process_enabled {
|
||||||
|
if was_active {
|
||||||
|
flush!(decoder);
|
||||||
|
was_active = false;
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mono = if channels > 1 {
|
||||||
|
let num_frames = frame.len() / channels as usize;
|
||||||
|
let mut mono = Vec::with_capacity(num_frames);
|
||||||
|
for i in 0..num_frames {
|
||||||
|
mono.push(frame[i * channels as usize]);
|
||||||
|
}
|
||||||
|
mono
|
||||||
|
} else {
|
||||||
|
frame
|
||||||
|
};
|
||||||
|
|
||||||
|
was_active = true;
|
||||||
|
let events = tokio::task::block_in_place(|| {
|
||||||
|
let _span = info_span!("sstv_decode").entered();
|
||||||
|
decoder.process_samples(&mono)
|
||||||
|
});
|
||||||
|
|
||||||
|
let latest_reset_seq = state_rx.borrow().reset_seqs.sstv_decode_reset_seq;
|
||||||
|
if latest_reset_seq != reset_seq {
|
||||||
|
last_reset_seq = latest_reset_seq;
|
||||||
|
flush!(decoder);
|
||||||
|
info!("SSTV decoder reset (seq={})", last_reset_seq);
|
||||||
|
pcm_rx = pcm_rx.resubscribe();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
SstvEvent::Started { vis, mode, width, height } => {
|
||||||
|
info!("SSTV: receiving {} (VIS {})", mode, vis);
|
||||||
|
let _ = decode_tx.send(DecodedMessage::SstvProgress(
|
||||||
|
trx_core::decode::SstvProgress {
|
||||||
|
rig_id: None,
|
||||||
|
ts_ms: Some(now_ms()),
|
||||||
|
vis,
|
||||||
|
mode: mode.to_string(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
line: 0,
|
||||||
|
line_data: None,
|
||||||
|
state: Some(format!("Receiving {mode}")),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SstvEvent::Row { line, rgb } => {
|
||||||
|
// One row per message: the picture is meant
|
||||||
|
// to be watched arriving, and a row of a
|
||||||
|
// 640-wide mode is under 2 kB.
|
||||||
|
let _ = decode_tx.send(DecodedMessage::SstvProgress(
|
||||||
|
trx_core::decode::SstvProgress {
|
||||||
|
rig_id: None,
|
||||||
|
ts_ms: Some(now_ms()),
|
||||||
|
vis: 0,
|
||||||
|
mode: String::new(),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
line,
|
||||||
|
line_data: Some({
|
||||||
|
use base64::Engine as _;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.encode(&rgb)
|
||||||
|
}),
|
||||||
|
state: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SstvEvent::Complete(image) => {
|
||||||
|
info!(
|
||||||
|
"SSTV: {} picture, {} of {} lines",
|
||||||
|
image.mode, image.lines, image.height
|
||||||
|
);
|
||||||
|
let freq_hz = state_rx.borrow().status.freq.hz;
|
||||||
|
let msg = sstv_message(&image, &output_dir, freq_hz);
|
||||||
|
if let DecodedMessage::Sstv(ref stored) = msg {
|
||||||
|
histories.record_sstv_message(stored.clone());
|
||||||
|
}
|
||||||
|
let _ = decode_tx.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
warn!("SSTV decoder: dropped {} PCM frames", n);
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = state_rx.changed() => {
|
||||||
|
match changed {
|
||||||
|
Ok(()) => {
|
||||||
|
let state = state_rx.borrow();
|
||||||
|
active = state.decoders.sstv_decode_enabled
|
||||||
|
&& is_sstv_mode(&state.status.mode);
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("SSTV decoder stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the epoch.
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Virtual-channel audio support
|
// Virtual-channel audio support
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -3078,6 +3322,8 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
||||||
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
||||||
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
||||||
|
DecodedMessage::Sstv(_) => AUDIO_MSG_SSTV_DECODE,
|
||||||
|
DecodedMessage::SstvProgress(_) => AUDIO_MSG_SSTV_PROGRESS,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||||
@@ -3110,6 +3356,8 @@ async fn handle_audio_client(
|
|||||||
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
|
||||||
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
|
||||||
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
|
||||||
|
DecodedMessage::Sstv(_) => AUDIO_MSG_SSTV_DECODE,
|
||||||
|
DecodedMessage::SstvProgress(_) => AUDIO_MSG_SSTV_PROGRESS,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||||
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, SstvMessage, VdesMessage, WefaxMessage,
|
||||||
|
WsprMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::history_policy::{
|
use crate::history_policy::{
|
||||||
@@ -36,6 +37,7 @@ pub struct DecoderHistories {
|
|||||||
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||||
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
||||||
|
pub sstv: Mutex<VecDeque<(Instant, SstvMessage)>>,
|
||||||
/// Approximate total entry count across all decoders, maintained
|
/// Approximate total entry count across all decoders, maintained
|
||||||
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
||||||
total_count: AtomicUsize,
|
total_count: AtomicUsize,
|
||||||
@@ -55,6 +57,7 @@ impl DecoderHistories {
|
|||||||
wspr: Mutex::new(VecDeque::new()),
|
wspr: Mutex::new(VecDeque::new()),
|
||||||
lrpt: Mutex::new(VecDeque::new()),
|
lrpt: Mutex::new(VecDeque::new()),
|
||||||
wefax: Mutex::new(VecDeque::new()),
|
wefax: Mutex::new(VecDeque::new()),
|
||||||
|
sstv: Mutex::new(VecDeque::new()),
|
||||||
total_count: AtomicUsize::new(0),
|
total_count: AtomicUsize::new(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -412,6 +415,42 @@ impl DecoderHistories {
|
|||||||
self.adjust_total_count(before, 0);
|
self.adjust_total_count(before, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SSTV ---
|
||||||
|
|
||||||
|
fn prune_sstv(history: &mut VecDeque<(Instant, SstvMessage)>) {
|
||||||
|
prune_by_age(history, HISTORY_RETENTION, Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_sstv_message(&self, mut msg: SstvMessage) {
|
||||||
|
if msg.ts_ms.is_none() {
|
||||||
|
msg.ts_ms = Some(current_timestamp_ms());
|
||||||
|
}
|
||||||
|
// The picture is on disk; a megabyte of base64 per entry is not what
|
||||||
|
// the history is for.
|
||||||
|
msg.png_data = None;
|
||||||
|
let mut h = lock_or_recover(&self.sstv, "sstv_history");
|
||||||
|
let before = h.len();
|
||||||
|
h.push_back((Instant::now(), msg));
|
||||||
|
Self::prune_sstv(&mut h);
|
||||||
|
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
|
||||||
|
self.adjust_total_count(before, h.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_sstv_history(&self) -> Vec<SstvMessage> {
|
||||||
|
let mut h = lock_or_recover(&self.sstv, "sstv_history");
|
||||||
|
let before = h.len();
|
||||||
|
Self::prune_sstv(&mut h);
|
||||||
|
self.adjust_total_count(before, h.len());
|
||||||
|
h.iter().map(|(_, m)| m.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_sstv_history(&self) {
|
||||||
|
let mut h = lock_or_recover(&self.sstv, "sstv_history");
|
||||||
|
let before = h.len();
|
||||||
|
h.clear();
|
||||||
|
self.adjust_total_count(before, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- WEFAX ---
|
// --- WEFAX ---
|
||||||
|
|
||||||
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
|
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
|
||||||
@@ -483,7 +522,8 @@ impl DecoderHistories {
|
|||||||
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
||||||
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
||||||
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
||||||
+ lock_or_recover(&self.wefax, "wefax_history").len();
|
+ lock_or_recover(&self.wefax, "wefax_history").len()
|
||||||
|
+ lock_or_recover(&self.sstv, "sstv_history").len();
|
||||||
self.total_count.store(total, Ordering::Relaxed);
|
self.total_count.store(total, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
|||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, SstvMessage, VdesMessage, WefaxMessage,
|
||||||
|
WsprMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::audio::DecoderHistories;
|
use crate::audio::DecoderHistories;
|
||||||
@@ -158,6 +159,11 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
|
|||||||
h.push_back(e);
|
h.push_back(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Ok(mut h) = histories.sstv.lock() {
|
||||||
|
for e in load_key::<SstvMessage>(db, &k("sstv")) {
|
||||||
|
h.push_back(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
histories.recalculate_total_count();
|
histories.recalculate_total_count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +229,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
|||||||
drop(h);
|
drop(h);
|
||||||
save_key(db, &k("wefax"), &snapshot);
|
save_key(db, &k("wefax"), &snapshot);
|
||||||
}
|
}
|
||||||
|
if let Ok(h) = histories.sstv.lock() {
|
||||||
|
let snapshot = h.clone();
|
||||||
|
drop(h);
|
||||||
|
save_key(db, &k("sstv"), &snapshot);
|
||||||
|
}
|
||||||
let _ = db.dump();
|
let _ = db.dump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -834,6 +834,25 @@ fn spawn_rig_audio_stack(
|
|||||||
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Spawn SSTV decoder task
|
||||||
|
let sstv_pcm_rx = pcm_tx.subscribe();
|
||||||
|
let sstv_state_rx = state_rx.clone();
|
||||||
|
let sstv_decode_tx = decode_tx.clone();
|
||||||
|
let sstv_sr = rig_cfg.audio.sample_rate;
|
||||||
|
let sstv_ch = rig_cfg.audio.channels;
|
||||||
|
let sstv_shutdown_rx = shutdown_rx.clone();
|
||||||
|
let sstv_histories = histories.clone();
|
||||||
|
let sstv_output_dir = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
|
||||||
|
.join("trx-rs")
|
||||||
|
.join("sstv");
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
|
||||||
|
_ = wait_for_shutdown(sstv_shutdown_rx) => {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if rig_cfg.audio.tx_enabled {
|
if rig_cfg.audio.tx_enabled {
|
||||||
|
|||||||
@@ -657,6 +657,18 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
|
RigCommand::SetSstvDecodeEnabled(en) => {
|
||||||
|
ctx.state.decoders.sstv_decode_enabled = en;
|
||||||
|
info!("SSTV decode {}", if en { "enabled" } else { "disabled" });
|
||||||
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
|
return snapshot_from(ctx.state);
|
||||||
|
}
|
||||||
|
RigCommand::ResetSstvDecoder => {
|
||||||
|
ctx.histories.clear_sstv_history();
|
||||||
|
ctx.state.reset_seqs.sstv_decode_reset_seq += 1;
|
||||||
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
|
return snapshot_from(ctx.state);
|
||||||
|
}
|
||||||
RigCommand::SetBandwidth(hz) => {
|
RigCommand::SetBandwidth(hz) => {
|
||||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
if let Some(sdr) = ctx.rig.as_sdr() {
|
||||||
if let Err(e) = sdr.set_bandwidth(hz).await {
|
if let Err(e) = sdr.set_bandwidth(hz).await {
|
||||||
|
|||||||
Reference in New Issue
Block a user