diff --git a/Cargo.lock b/Cargo.lock index 705418e7..1d7d099b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,6 +3300,7 @@ dependencies = [ "trx-ftx", "trx-protocol", "trx-reporting", + "trx-sstv", "trx-vdes", "trx-wefax", "trx-wspr", diff --git a/src/decoders/trx-sstv/src/decoder.rs b/src/decoders/trx-sstv/src/decoder.rs index db71d18d..f9f51509 100644 --- a/src/decoders/trx-sstv/src/decoder.rs +++ b/src/decoders/trx-sstv/src/decoder.rs @@ -79,6 +79,61 @@ pub struct SstvImage { pub started_ms: i64, } +impl SstvImage { + /// The picture as a canvas, for saving or encoding. + pub fn canvas(&self) -> ImageCanvas { + let width = usize::from(self.width); + let mut canvas = ImageCanvas::new(width, usize::from(self.height)); + for (y, row) in self.rgb.chunks(width * 3).enumerate() { + canvas.put_row(y, row); + } + canvas + } + + /// The picture as PNG bytes. + pub fn to_png(&self) -> Result, String> { + self.canvas().to_png() + } + + /// The picture as a base64 PNG, for the journey to a client. + pub fn to_png_base64(&self) -> Result { + 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 { + self.canvas() + .save_png(dir, freq_hz, self.mode, &stamp(self.started_ms)) + } +} + +/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort. +fn stamp(ms: i64) -> String { + let secs = ms.div_euclid(1000); + let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (year, month, day) = civil_from_days(days); + let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60); + format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z") +} + +/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + enum State { /// Listening for a header. Searching, @@ -587,3 +642,18 @@ fn finish(reception: &Reception) -> SstvImage { started_ms: reception.started_ms, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_stamps_are_utc_and_sort_in_time_order() { + // Known instants, checked against `date -u -r `. + 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)); + } +} diff --git a/src/trx-client/src/audio_client.rs b/src/trx-client/src/audio_client.rs index 593c6b8b..f739ff6b 100644 --- a/src/trx-client/src/audio_client.rs +++ b/src/trx-client/src/audio_client.rs @@ -27,11 +27,11 @@ use trx_core::audio::{ write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE, - AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO, - AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, - AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, - AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, - AUDIO_MSG_WSPR_DECODE, + AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE, + AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, + AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, + AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, + AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE, }; use trx_core::decode::DecodedMessage; use trx_frontend::VChanAudioCmd; @@ -533,7 +533,9 @@ async fn handle_single_rig_connection( | AUDIO_MSG_LRPT_IMAGE | AUDIO_MSG_LRPT_PROGRESS | AUDIO_MSG_WEFAX_DECODE - | AUDIO_MSG_WEFAX_PROGRESS, + | AUDIO_MSG_WEFAX_PROGRESS + | AUDIO_MSG_SSTV_DECODE + | AUDIO_MSG_SSTV_PROGRESS, payload, )) => { if let Ok(mut msg) = serde_json::from_slice::(&payload) { diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index dfc8209e..161f929b 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -527,6 +527,9 @@ async fn async_init() -> DynResult { DecodedMessage::LrptProgress(_) => {} DecodedMessage::Wefax(_) => {} DecodedMessage::WefaxProgress(_) => {} + // Pictures replay from their own history, not this one. + DecodedMessage::Sstv(_) => {} + DecodedMessage::SstvProgress(_) => {} } }); diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 5ad361ab..689acf63 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -17,8 +17,8 @@ use uuid::Uuid; use trx_core::audio::AudioStreamInfo; use trx_core::decode::{ - AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage, - WsprMessage, + AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage, + WefaxMessage, WsprMessage, }; use trx_core::rig::state::{RigSnapshot, SpectrumData}; use trx_core::{DynResult, RigRequest, RigState}; @@ -233,6 +233,7 @@ pub struct DecodeHistoryContext { pub ft2: DecodeHistory, pub wspr: DecodeHistory, pub wefax: DecodeHistory, + pub sstv: DecodeHistory, } impl Default for DecodeHistoryContext { @@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext { ft2: Arc::new(Mutex::new(VecDeque::new())), wspr: Arc::new(Mutex::new(VecDeque::new())), wefax: Arc::new(Mutex::new(VecDeque::new())), + sstv: Arc::new(Mutex::new(VecDeque::new())), } } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 592d42ab..c9dd4208 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -932,7 +932,7 @@ function elementById(id) { ["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]], ["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], - ["Broadcast & images", ["rds", "sat", "wefax"]] + ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]] ]; groups.forEach(([label, ids]) => { const group = document.createElement("optgroup"); @@ -1748,6 +1748,7 @@ var pluginGroups = { "/background-decode.js", "/sat.js", "/wefax.js", + "/sstv.js", "/ais.js", "/vdes.js", "/aprs.js", @@ -2240,7 +2241,7 @@ function currentDecodeHistoryRetentionMs() { } window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.applyDecodeHistoryRetention = function() { - for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) { + for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) { window.trxPluginRuntime.prune(decoder); } }; @@ -4820,6 +4821,9 @@ function render(update) { for (const [key, entry] of Object.entries(_decoderToggles)) { syncDecoderToggle(entry, !!update[key], entry.label); } + if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) { + window.syncSstvToggle(update.sstv_decode_enabled); + } if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) { window.syncWefaxToggle(update.wefax_decode_enabled); } @@ -7594,9 +7598,17 @@ function updateDecodeStatus(text) { if (el && el.textContent !== "Receiving") el.textContent = text; } } +var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([ + "lrpt_image", + "lrpt_progress", + "wefax", + "wefax_progress", + "sstv", + "sstv_progress" +]); function dispatchDecodeMessage(msg, skipStats = false) { if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); - if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") { + if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); window.trx.modules.map?.scheduleStatsRender(); } @@ -7642,7 +7654,7 @@ function loadDecodeHistoryOnMainThread(onReady, onError) { } function restoreDecodeHistoryGroup(kind, messages) { if (!Array.isArray(messages) || messages.length === 0) return; - if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") { + if (!IMAGE_DECODE_KINDS.has(kind)) { for (const msg of messages) { window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js index 7a4fd220..85832b20 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js @@ -2,7 +2,7 @@ (() => { // src/decode-history-worker.ts var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; - var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; + var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]; var workerScope = self; function decodeCborUint(view, bytes, state, additional) { const offset = state.offset; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js new file mode 100644 index 00000000..e92e9e21 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js @@ -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, """); +} +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 = ` +
+
+ ${escapeHtml(latest.mode ?? "SSTV")} + ${escapeHtml(latest._ts ?? "")} · ${lines} · ${state} +
+ ${url ? ` + Received ${escapeHtml(latest.mode ?? + ` : ""} +
`; +} +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 `
+ ${escapeHtml(image._ts ?? "")} + ${escapeHtml(image.mode ?? "--")} + ${escapeHtml(size)} + ${escapeHtml(lines)} + ${url ? `View` : "--"} +
`; + }).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 +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 7f8def65..49a8b34f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -584,6 +584,7 @@ SPDX-License-Identifier: GPL-2.0-or-later +
@@ -646,6 +647,12 @@ SPDX-License-Identifier: GPL-2.0-or-later Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
+
+ SSTV Decoder +
+ Slow-Scan Television — pictures in Martin, Scottie, Robot, PD and Wraase modes +
+
WEFAX Decoder
@@ -653,6 +660,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
+