[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:
@@ -23,6 +23,7 @@ await build({
|
||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||
|
||||
@@ -51,7 +51,7 @@ export type RigRxStatus = { sig: number | null, };
|
||||
|
||||
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
|
||||
|
||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
|
||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||
|
||||
@@ -111,7 +111,7 @@ export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | nul
|
||||
/**
|
||||
* Per-virtual-channel RDS snapshots, when available.
|
||||
*/
|
||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, sstv_decode_enabled: boolean, recorder_enabled: boolean, };
|
||||
|
||||
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
||||
|
||||
|
||||
@@ -386,6 +386,7 @@ declare global {
|
||||
updateFt8RfDisplay?(): void;
|
||||
clearSatPredictionDom?(): void;
|
||||
syncWefaxToggle?(enabled: boolean): void;
|
||||
syncSstvToggle?(enabled: boolean): void;
|
||||
updateAisBar?(value?: number): void;
|
||||
updateVdesBar?(value?: number): void;
|
||||
updateAprsBar?(value?: number): void;
|
||||
@@ -858,7 +859,7 @@ function currentDecodeHistoryRetentionMs() {
|
||||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||||
|
||||
window.applyDecodeHistoryRetention = function() {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
||||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"]) {
|
||||
window.trxPluginRuntime.prune(decoder);
|
||||
}
|
||||
};
|
||||
@@ -3712,7 +3713,11 @@ function render(update: AppUpdate) {
|
||||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||||
syncDecoderToggle(entry, !!update[key], entry.label);
|
||||
}
|
||||
// WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles).
|
||||
// Image decoders own their own toggle buttons; keep them in step with the
|
||||
// rig state as well as with the click that set it.
|
||||
if (typeof update.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||||
window.syncSstvToggle(update.sstv_decode_enabled);
|
||||
}
|
||||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||||
}
|
||||
@@ -6567,9 +6572,15 @@ function updateDecodeStatus(text: string) {
|
||||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||||
}
|
||||
}
|
||||
// Picture decoders produce one message per image and a stream of progress
|
||||
// updates; neither is a spot, and counting them would swamp the statistics.
|
||||
const IMAGE_DECODE_KINDS = new Set([
|
||||
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||
]);
|
||||
|
||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||
window.trx.modules.map?.scheduleStatsRender();
|
||||
}
|
||||
@@ -6619,7 +6630,7 @@ function loadDecodeHistoryOnMainThread(onReady: (groups: DecodeHistoryGroups) =>
|
||||
function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||
// Record statistics for restored history messages.
|
||||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||
for (const msg of messages) {
|
||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export {};
|
||||
|
||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"] as const;
|
||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
||||
interface DecodeState { offset: number }
|
||||
|
||||
@@ -11,7 +11,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||||
"digital-modes": [
|
||||
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
|
||||
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||
"/sat.js", "/wefax.js", "/sstv.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
|
||||
],
|
||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||
|
||||
@@ -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");
|
||||
const groups: Array<[string, string[]]> = [
|
||||
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
||||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax", "sstv"]],
|
||||
];
|
||||
groups.forEach(([label, ids]) => {
|
||||
const group = document.createElement("optgroup");
|
||||
|
||||
@@ -0,0 +1,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: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
|
||||
{ id: "sstv", label: "SSTV", activation: "toggle", active_modes: ["USB", "FM"] },
|
||||
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||
@@ -104,7 +105,7 @@ function encodeCbor(value) {
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||
|
||||
function assetPath(urlPath) {
|
||||
// Every tab route has its own index handler on the server (see api/assets.rs),
|
||||
@@ -203,6 +204,7 @@ export async function startWebFixture({
|
||||
wspr_decode_enabled: false,
|
||||
lrpt_decode_enabled: false,
|
||||
wefax_decode_enabled: false,
|
||||
sstv_decode_enabled: false,
|
||||
recorder_enabled: false,
|
||||
clients: 1,
|
||||
rigctl_clients: 0,
|
||||
|
||||
Reference in New Issue
Block a user