From 4500b10fad8cb57f7dca87172ebeca88de59951e Mon Sep 17 00:00:00 2001 From: Ceane <1743355+ceane@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:17:44 -0800 Subject: [PATCH 1/4] WIP: Make file placeholder state explicit --- .../infrastructure/io/filePlaceholderState.ts | 42 ++++++++++++------- src/ts/app/routes/pages/SpectrumRoute.tsx | 7 +++- .../capture/DemodFilePlaybackBridge.tsx | 4 +- .../demodulation/react-flow/nodes/FFTNode.tsx | 4 ++ .../react-flow/nodes/WaterfallNode.tsx | 4 ++ .../features/spectrum/FFTPlaybackCanvas.tsx | 3 +- .../spectrum/hooks/useStitchingLogic.ts | 26 +++++++----- src/ts/redux/index.ts | 1 + src/ts/redux/slices/waterfallSlice.ts | 11 +++++ test/ts/filePlaceholderState.test.ts | 5 +++ 10 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/ts/app/infrastructure/io/filePlaceholderState.ts b/src/ts/app/infrastructure/io/filePlaceholderState.ts index 81dedd3d8..3a24a5471 100644 --- a/src/ts/app/infrastructure/io/filePlaceholderState.ts +++ b/src/ts/app/infrastructure/io/filePlaceholderState.ts @@ -1,19 +1,19 @@ +import type { StitchState } from "@n-apt/redux/slices/waterfallSlice"; import type { CanvasPlaceholderState } from "@n-apt/ui/CanvasPlaceholder"; type FilePlaceholderInput = { sourceMode: "live" | "file"; selectedFilesCount: number; stitchStatus?: string | null; + stitchState: StitchState; hasRenderableFrame: boolean; }; -const isProcessingStatus = (status: string) => - /loading|processing|stitch/i.test(status); - export const getFilePlaceholderState = ({ sourceMode, selectedFilesCount, stitchStatus, + stitchState, hasRenderableFrame, }: FilePlaceholderInput): CanvasPlaceholderState | null => { if (sourceMode !== "file") return null; @@ -22,19 +22,8 @@ export const getFilePlaceholderState = ({ return { kind: "idle", title: "Upload a file" }; } - // A successful processing status can arrive before the playback loop emits - // its seed frame. Keep the processing placeholder until that frame exists. - if (hasRenderableFrame) { - return null; - } - const normalizedStatus = stitchStatus?.trim() ?? ""; - if ( - normalizedStatus.length > 0 && - !isProcessingStatus(normalizedStatus) && - !/success|processed|stitched/i.test(normalizedStatus) && - !/no files selected/i.test(normalizedStatus) - ) { + if (stitchState === "error") { if (/integrity|corrupt|modified/i.test(normalizedStatus)) { return { kind: "error", @@ -43,10 +32,31 @@ export const getFilePlaceholderState = ({ message: "Its contents do not match the integrity record.", }; } + if (/fido vault/i.test(normalizedStatus)) { + return { + kind: "error", + reason: "File decryption unavailable", + title: "Unlock the FIDO vault to process this file", + message: normalizedStatus, + }; + } return { kind: "error", reason: "File processing error", - title: "File processing error", + title: "File processing failed", + message: normalizedStatus || undefined, + }; + } + + // A successful processing state can arrive before the playback loop emits + // its seed frame. Keep the processing placeholder until that frame exists. + if (hasRenderableFrame) return null; + + if (stitchState === "idle") { + return { + kind: "idle", + title: "File selected", + message: "Click Stitch/Process to visualize.", }; } diff --git a/src/ts/app/routes/pages/SpectrumRoute.tsx b/src/ts/app/routes/pages/SpectrumRoute.tsx index e6429c006..468a3dfdc 100644 --- a/src/ts/app/routes/pages/SpectrumRoute.tsx +++ b/src/ts/app/routes/pages/SpectrumRoute.tsx @@ -49,6 +49,7 @@ import { useCaptureWholeChannelSegments } from "@n-apt/capture/hooks/useCaptureW import { useGeolocation } from "@n-apt/maps/public/useGeolocation"; import { reverseGeocodeSnapshotLocation } from "@n-apt/capture/snapshotLocation"; import type { NoteCardStatsSnapshot } from "@n-apt/redux/slices/noteCardsSlice"; +import type { StitchState } from "@n-apt/redux/slices/waterfallSlice"; import { useAppSelector, useAppDispatch, @@ -63,6 +64,7 @@ import { setFrequencyRange, setSourceViewFrequencyRange, setStitchStatus, + setStitchState, resetWaterfallCleared, setVizZoom as setVizZoomAction, setVizZoomFloor as setVizZoomFloorAction, @@ -1032,7 +1034,10 @@ export const SpectrumRoute: React.FC = ({ [], ); const handleStitchStatusChange = useCallback( - (status: string) => reduxDispatch(setStitchStatus(status)), + (status: string, stitchState: StitchState) => { + reduxDispatch(setStitchStatus(status)); + reduxDispatch(setStitchState(stitchState)); + }, [reduxDispatch], ); const handleNoopSnapshot = useCallback(() => {}, []); diff --git a/src/ts/features/capture/DemodFilePlaybackBridge.tsx b/src/ts/features/capture/DemodFilePlaybackBridge.tsx index 8f5e8f8dc..2723303bb 100644 --- a/src/ts/features/capture/DemodFilePlaybackBridge.tsx +++ b/src/ts/features/capture/DemodFilePlaybackBridge.tsx @@ -3,6 +3,7 @@ import { useAppDispatch } from "@n-apt/redux"; import { clearActivePlaybackMetadata, setStitchStatus, + setStitchState, setActivePlaybackMetadata, } from "@n-apt/redux"; import { useStitchingLogic } from "@n-apt/spectrum/public/useStitchingLogic"; @@ -50,8 +51,9 @@ export const DemodFilePlaybackBridge: React.FC< stitchTrigger, stitchSourceSettings, fftSize, - onStitchStatus: (status) => { + onStitchStatus: (status, state) => { dispatch(setStitchStatus(status)); + dispatch(setStitchState(state)); onStitchStatus?.(status); }, }); diff --git a/src/ts/features/demodulation/react-flow/nodes/FFTNode.tsx b/src/ts/features/demodulation/react-flow/nodes/FFTNode.tsx index bdafb0fb0..8ca4e67bc 100644 --- a/src/ts/features/demodulation/react-flow/nodes/FFTNode.tsx +++ b/src/ts/features/demodulation/react-flow/nodes/FFTNode.tsx @@ -251,6 +251,9 @@ const FFTNodeComponent: React.FC = ({ id, data }) => { (state) => state.waterfall.selectedFiles, ); const stitchStatus = useAppSelector((state) => state.waterfall.stitchStatus); + const stitchState = useAppSelector( + (state) => state.waterfall?.stitchState ?? "idle", + ); const stitchTrigger = useAppSelector( (state) => state.waterfall.stitchTrigger, ); @@ -521,6 +524,7 @@ const FFTNodeComponent: React.FC = ({ id, data }) => { sourceMode, selectedFilesCount: selectedFiles.length, stitchStatus, + stitchState, hasRenderableFrame: Boolean( (frame as any)?.waveform?.length || (frame as any)?.iq_data?.length || diff --git a/src/ts/features/demodulation/react-flow/nodes/WaterfallNode.tsx b/src/ts/features/demodulation/react-flow/nodes/WaterfallNode.tsx index 16a19a45a..6d3223144 100644 --- a/src/ts/features/demodulation/react-flow/nodes/WaterfallNode.tsx +++ b/src/ts/features/demodulation/react-flow/nodes/WaterfallNode.tsx @@ -624,6 +624,9 @@ const WaterfallNodeComponent: React.FC = ({ data }) => { const stitchStatus = useAppSelector( (state) => state.waterfall?.stitchStatus ?? "", ); + const stitchState = useAppSelector( + (state) => state.waterfall?.stitchState ?? "idle", + ); const stitchTrigger = useAppSelector( (state) => state.waterfall?.stitchTrigger ?? 0, ); @@ -832,6 +835,7 @@ const WaterfallNodeComponent: React.FC = ({ data }) => { sourceMode, selectedFilesCount: selectedFiles.length, stitchStatus, + stitchState, hasRenderableFrame: Boolean(waveform?.length), }); const isPaused = diff --git a/src/ts/features/spectrum/FFTPlaybackCanvas.tsx b/src/ts/features/spectrum/FFTPlaybackCanvas.tsx index c6c7cac90..1e7423fea 100644 --- a/src/ts/features/spectrum/FFTPlaybackCanvas.tsx +++ b/src/ts/features/spectrum/FFTPlaybackCanvas.tsx @@ -38,6 +38,7 @@ import { formatDuration } from "@n-apt/math/formatters"; import { fileFrameRuntime } from "@n-apt/app/infrastructure/visualization/frameRuntime"; import { shouldRestorePausedFrameSnapshot } from "@n-apt/spectrum/public/liveSourceLifecycle"; import { fileRegistry } from "@n-apt/app/infrastructure/io/fileRegistry"; +import type { StitchState } from "@n-apt/redux/slices/waterfallSlice"; import { DecryptionFallback } from "@n-apt/ui/DecryptionFallback"; import { useAuthentication } from "@n-apt/app/hooks/useAuthentication"; import type { AppStyledTheme } from "@n-apt/ui/Theme"; @@ -48,7 +49,7 @@ interface FFTPlaybackCanvasProps { stitchTrigger: number | null; stitchSourceSettings: { gain: number; ppm: number }; isPaused: boolean; - onStitchStatus?: (status: string) => void; + onStitchStatus?: (status: string, state: StitchState) => void; onFrequencyRangeChange?: (range: { min: number; max: number }) => void; snapshotGridPreference?: boolean; fftSize: number; diff --git a/src/ts/features/spectrum/hooks/useStitchingLogic.ts b/src/ts/features/spectrum/hooks/useStitchingLogic.ts index 8674945a4..0abf71e6a 100644 --- a/src/ts/features/spectrum/hooks/useStitchingLogic.ts +++ b/src/ts/features/spectrum/hooks/useStitchingLogic.ts @@ -6,13 +6,14 @@ import { getStitchSession, setStitchSession, } from "@n-apt/spectrum/utils/stitchSessionCache"; +import type { StitchState } from "@n-apt/redux/slices/waterfallSlice"; interface UseStitchingLogicProps { selectedFiles: { id: string; name: string }[]; stitchTrigger: number | null; stitchSourceSettings: { gain: number; ppm: number }; fftSize: number; - onStitchStatus?: (status: string) => void; + onStitchStatus?: (status: string, state: StitchState) => void; onChannelsChange?: (channels: any[]) => void; onProcessedDataChange?: (hasData: boolean) => void; sampleRateOptions?: { maxSampleRateHz: number; currentSampleRateHz: number }; @@ -145,8 +146,8 @@ export const useStitchingLogic = ({ }, [frequencyRange]); - const setStitchStatus = useCallback((status: string) => { - onStitchStatusRef.current?.(status); + const setStitchStatus = useCallback((status: string, state: StitchState) => { + onStitchStatusRef.current?.(status, state); }, []); // Reset stitched state when file selection changes @@ -156,7 +157,7 @@ export const useStitchingLogic = ({ const lastFileNames = lastProcessedFilesRef.current; if (JSON.stringify(currentFileNames) !== JSON.stringify(lastFileNames)) { setHasStitchedData(false); - onStitchStatusRef.current?.(""); + onStitchStatusRef.current?.("", "idle"); // Update this so we don't keep resetting until the next process lastProcessedFilesRef.current = currentFileNames; } @@ -194,7 +195,7 @@ export const useStitchingLogic = ({ const stitchFiles = useCallback(async (allowIntegrityFailure = false) => { const currentFiles = selectedFilesRef.current; if (currentFiles.length === 0) { - setStitchStatus("No files selected for stitching"); + setStitchStatus("No files selected for stitching", "idle"); return; } @@ -206,7 +207,7 @@ export const useStitchingLogic = ({ if (inFlightStitchKeysRef.current.has(inFlightKey)) return; inFlightStitchKeysRef.current.add(inFlightKey); - setStitchStatus(`Loading ${currentFiles.length} files...`); + setStitchStatus(`Loading ${currentFiles.length} files...`, "loading"); // Check if files have changed to force reset const currentFileNames = currentFiles.map((f) => f.name).sort(); @@ -236,6 +237,7 @@ export const useStitchingLogic = ({ setStitchStatus( progress.status || `Loading ${progress.current}/${progress.total} files...`, + "loading", ); }, aesKeyRef.current, @@ -317,7 +319,7 @@ export const useStitchingLogic = ({ currentFiles.length > 1 ? "Stitched Successfully" : "Processed Successfully"; - onStitchStatus?.(nextStitchStatus); + setStitchStatus(nextStitchStatus, "ready"); onChannelsChange?.(channels); onProcessedDataChange?.(true); setStitchSession(stitchSessionKey, { @@ -341,17 +343,21 @@ export const useStitchingLogic = ({ const msg = error.message || String(error); const normalizedMessage = msg.toLowerCase(); if (normalizedMessage.includes("integrity_failed")) { - setStitchStatus("File integrity failed: file appears corrupted or modified"); + setStitchStatus( + "File integrity failed: file appears corrupted or modified", + "error", + ); } else if ( normalizedMessage.includes("fido vault key is not available") ) { setStitchStatus( "FIDO vault decryption requires unlocking your security key", + "error", ); } else if (normalizedMessage.includes("decryption")) { - setStitchStatus("File decryption failed, wrong key"); + setStitchStatus("File decryption failed, wrong key", "error"); } else { - setStitchStatus(msg); + setStitchStatus(msg, "error"); } } finally { inFlightStitchKeysRef.current.delete(inFlightKey); diff --git a/src/ts/redux/index.ts b/src/ts/redux/index.ts index 48b86a195..cd0d2f336 100644 --- a/src/ts/redux/index.ts +++ b/src/ts/redux/index.ts @@ -322,6 +322,7 @@ export { setDrawSignal3D, setStitchPaused, setStitchStatus, + setStitchState, setStitchSourceSettings, setActivePlaybackMetadata, setPlaybackChannels, diff --git a/src/ts/redux/slices/waterfallSlice.ts b/src/ts/redux/slices/waterfallSlice.ts index 77ae9b3b4..6fb05fa11 100644 --- a/src/ts/redux/slices/waterfallSlice.ts +++ b/src/ts/redux/slices/waterfallSlice.ts @@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { NaptMetadata } from "@n-apt/consts/types"; export type SourceMode = "live" | "file"; +export type StitchState = "idle" | "loading" | "ready" | "error"; export type SelectedFile = { id: string; name: string; downloadUrl?: string }; export type TrainingLabel = "target" | "noise"; export type ActivePlaybackMetadata = { @@ -47,6 +48,7 @@ export interface WaterfallState { // Stitching and processing stitchStatus: string; + stitchState: StitchState; stitchTrigger: number; stitchSourceSettings: { gain: number; ppm: number }; isStitchPaused: boolean; @@ -92,6 +94,7 @@ const initialState: WaterfallState = { globalNoiseFloor: -100, // Default changed to -100dB stitchStatus: "", + stitchState: "idle", stitchTrigger: 0, stitchSourceSettings: { gain: 46.9, ppm: 1 }, isStitchPaused: false, @@ -119,6 +122,7 @@ const waterfallSlice = createSlice({ if (state.sourceMode === "file" && action.payload !== "file") { state.sourceMode = action.payload; state.stitchStatus = ""; + state.stitchState = "idle"; state.isStitchPaused = true; state.activePlaybackMetadata = null; state.loadedFileMetadata = null; @@ -136,6 +140,8 @@ const waterfallSlice = createSlice({ setSelectedFiles: (state, action: PayloadAction) => { state.selectedFiles = action.payload; + state.stitchStatus = ""; + state.stitchState = "idle"; state.activePlaybackMetadata = null; state.loadedFileMetadata = null; state.playbackFrameCounter = 0; @@ -175,10 +181,14 @@ const waterfallSlice = createSlice({ setStitchStatus: (state, action: PayloadAction) => { state.stitchStatus = action.payload; }, + setStitchState: (state, action: PayloadAction) => { + state.stitchState = action.payload; + }, triggerStitch: (state) => { state.isStitchPaused = true; state.stitchStatus = ""; + state.stitchState = "loading"; state.stitchTrigger += 1; state.playbackFrameCounter = 0; }, @@ -269,6 +279,7 @@ export const { setActiveClumpIndex, setGlobalNoiseFloor, setStitchStatus, + setStitchState, triggerStitch, toggleStitchPause, setStitchSourceSettings, diff --git a/test/ts/filePlaceholderState.test.ts b/test/ts/filePlaceholderState.test.ts index b28dcf468..1285a3453 100644 --- a/test/ts/filePlaceholderState.test.ts +++ b/test/ts/filePlaceholderState.test.ts @@ -7,6 +7,7 @@ describe("getFilePlaceholderState", () => { sourceMode: "file", selectedFilesCount: 0, stitchStatus: "", + stitchState: "idle", hasRenderableFrame: false, }), ).toEqual({ kind: "idle", title: "Upload a file" }); @@ -18,6 +19,7 @@ describe("getFilePlaceholderState", () => { sourceMode: "file", selectedFilesCount: 1, stitchStatus: "Loading 1 files...", + stitchState: "loading", hasRenderableFrame: false, }), ).toEqual({ kind: "loading", paneLabel: "file", title: "Processing file" }); @@ -29,6 +31,7 @@ describe("getFilePlaceholderState", () => { sourceMode: "file", selectedFilesCount: 1, stitchStatus: "Processed Successfully", + stitchState: "ready", hasRenderableFrame: true, }), ).toBeNull(); @@ -40,6 +43,7 @@ describe("getFilePlaceholderState", () => { sourceMode: "file", selectedFilesCount: 1, stitchStatus: "Processed Successfully", + stitchState: "ready", hasRenderableFrame: false, }), ).toEqual({ kind: "loading", paneLabel: "file", title: "Processing file" }); @@ -51,6 +55,7 @@ describe("getFilePlaceholderState", () => { sourceMode: "file", selectedFilesCount: 1, stitchStatus: "invalid file header", + stitchState: "error", hasRenderableFrame: false, }), ).toEqual({ From fb1315c33892531db88565b1db77fb1f12a7241b Mon Sep 17 00:00:00 2001 From: Ceane <1743355+ceane@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:05:25 -0800 Subject: [PATCH 2/4] Draft: show waterfall minimum in fast exports --- .../layout/rendering/waterfallDbAxis.ts | 6 ++++++ test/ts/waterfallFastSnapshot.test.tsx | 20 +++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/ts/shared/layout/rendering/waterfallDbAxis.ts b/src/ts/shared/layout/rendering/waterfallDbAxis.ts index 3f6579adf..a37a51606 100644 --- a/src/ts/shared/layout/rendering/waterfallDbAxis.ts +++ b/src/ts/shared/layout/rendering/waterfallDbAxis.ts @@ -47,6 +47,12 @@ function getDbMarkers(dbMin: number, dbMax: number): number[] { if (Math.abs(marker - dbMax) >= 0.1) markers.push(marker); } + // The floor may fall between the ten-decibel grid marks, including when + // the entire displayed range is narrower than one grid interval. + if (!markers.some((marker) => Math.abs(marker - dbMin) < 0.1)) { + markers.push(dbMin); + } + return markers.filter((marker) => marker >= dbMin && marker <= dbMax); } diff --git a/test/ts/waterfallFastSnapshot.test.tsx b/test/ts/waterfallFastSnapshot.test.tsx index 690e2a17a..4995e36de 100644 --- a/test/ts/waterfallFastSnapshot.test.tsx +++ b/test/ts/waterfallFastSnapshot.test.tsx @@ -44,8 +44,8 @@ describe("standalone waterfall fast snapshot", () => { const spectrumGpu = document.createElement("canvas"); const snapshotData = { frequencyRange: { min: 18_000, max: 3_218_000 }, - dbMin: -60, - dbMax: 0, + dbMin: -57, + dbMax: -52, powerScale: "dBm" as const, vizZoom: 1, vizPanOffset: 0, @@ -75,8 +75,8 @@ describe("standalone waterfall fast snapshot", () => { const labels = (global as any).__CANVAS_CALLS__ .filter((call: any) => call.name === "fillText") .map((call: any) => call.args[0]); - expect(labels).toContain("0dBm"); - expect(labels).toContain("-10"); + expect(labels).toContain("-52dBm"); + expect(labels).toContain("-57"); const calls = (global as any).__CANVAS_CALLS__ as Array<{ name: string; @@ -107,10 +107,10 @@ describe("standalone waterfall fast snapshot", () => { ]); const dbMaxLabel = calls.find( - (call) => call.name === "fillText" && call.args[0] === "0dBm", + (call) => call.name === "fillText" && call.args[0] === "-52dBm", ); const dbMinLabel = calls.find( - (call) => call.name === "fillText" && call.args[0] === "-60", + (call) => call.name === "fillText" && call.args[0] === "-57", ); const sourceWaterfallCanvas = plotCopy?.args[0] as HTMLCanvasElement; const fullWidthHeaderCopy = calls.find( @@ -223,8 +223,8 @@ describe("standalone waterfall fast snapshot", () => { const waterfallData = { frequencyRange: { min: 18_000, max: 3_218_000 }, waveform: new Float32Array([-45, -30, -38, -24]), - dbMin: -60, - dbMax: 0, + dbMin: -57, + dbMax: -52, powerScale: "dBm" as const, vizZoom: 1, vizPanOffset: 0, @@ -294,8 +294,8 @@ describe("standalone waterfall fast snapshot", () => { const labels = (global as any).__CANVAS_CALLS__ .filter((call: any) => call.name === "fillText") .map((call: any) => call.args[0]); - expect(labels).toContain("0dBm"); - expect(labels).toContain("-10"); + expect(labels).toContain("-52dBm"); + expect(labels).toContain("-57"); const calls = (global as any).__CANVAS_CALLS__ as Array<{ name: string; args: any[]; From fd7e7b546b9aff259ae7b1fd89aca0b5cfc9c207 Mon Sep 17 00:00:00 2001 From: Ceane <1743355+ceane@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:08:37 -0800 Subject: [PATCH 3/4] Draft: show exact waterfall export bounds --- .../layout/rendering/waterfallDbAxis.ts | 32 ++++--------------- test/ts/waterfallDbAxis.test.ts | 28 ++++++++++------ test/ts/waterfallFastSnapshot.test.tsx | 22 ++++++------- 3 files changed, 35 insertions(+), 47 deletions(-) diff --git a/src/ts/shared/layout/rendering/waterfallDbAxis.ts b/src/ts/shared/layout/rendering/waterfallDbAxis.ts index a37a51606..69ebe0908 100644 --- a/src/ts/shared/layout/rendering/waterfallDbAxis.ts +++ b/src/ts/shared/layout/rendering/waterfallDbAxis.ts @@ -29,33 +29,11 @@ const BOTTOM_PADDING_PX = 40; const BOTTOM_GUTTER_PX = 8; const LABEL_FONT_SIZE_PX = 10; const LABEL_INSET_PX = 10; -const DB_MARKER_STEP = 10; function clampInset(inset: number, size: number): number { return Number.isFinite(inset) ? Math.max(0, Math.min(size, inset)) : 0; } -function getDbMarkers(dbMin: number, dbMax: number): number[] { - const markers = [dbMax]; - const firstStep = Math.floor(dbMax / DB_MARKER_STEP) * DB_MARKER_STEP; - - for ( - let marker = firstStep; - marker >= dbMin - 1; - marker -= DB_MARKER_STEP - ) { - if (Math.abs(marker - dbMax) >= 0.1) markers.push(marker); - } - - // The floor may fall between the ten-decibel grid marks, including when - // the entire displayed range is narrower than one grid interval. - if (!markers.some((marker) => Math.abs(marker - dbMin) < 0.1)) { - markers.push(dbMin); - } - - return markers.filter((marker) => marker >= dbMin && marker <= dbMax); -} - export function composeWaterfallCanvasWithDbAxis({ waterfallCanvas, dbMin, @@ -91,7 +69,10 @@ export function composeWaterfallCanvasWithDbAxis({ const plotWidth = waterfallCanvas.width - leftCrop - rightCrop; if (plotWidth <= 0) return waterfallCanvas; - const gutterWidth = Math.round(AXIS_GUTTER_WIDTH_PX * dpr); + const labels = [`${dbMax}${unit}`, `${dbMin}`]; + // Reserve enough room for fractional values without clipping the left edge. + const labelWidth = Math.max(...labels.map((label) => label.length)) * LABEL_FONT_SIZE_PX * 0.7; + const gutterWidth = Math.ceil(Math.max(AXIS_GUTTER_WIDTH_PX, labelWidth + LABEL_INSET_PX) * dpr); const rightPadding = Math.round(RIGHT_PADDING_PX * dpr); const targetPlotWidth = typeof referencePlotWidth === "number" && @@ -186,14 +167,13 @@ export function composeWaterfallCanvasWithDbAxis({ context.lineWidth = Math.max(1, dpr); context.beginPath(); - const markers = getDbMarkers(dbMin, dbMax); + const markers = [dbMax, dbMin]; markers.forEach((db, index) => { const y = markerY(db); context.moveTo(gutterWidth - 4 * dpr, y); context.lineTo(gutterWidth, y); - const label = `${Math.round(db)}${index === 0 ? unit : ""}`; - context.fillText(label, gutterWidth - markerInset, y); + context.fillText(labels[index], gutterWidth - markerInset, y); }); context.stroke(); diff --git a/test/ts/waterfallDbAxis.test.ts b/test/ts/waterfallDbAxis.test.ts index 496919f69..5e1224446 100644 --- a/test/ts/waterfallDbAxis.test.ts +++ b/test/ts/waterfallDbAxis.test.ts @@ -105,22 +105,30 @@ describe("waterfall dB axis composition", () => { 456, 380, ); - expect(labels.map(({ text }) => text)).toEqual([ - "0dBm", - "-10", - "-20", - "-30", - "-40", - "-50", - "-60", - ]); + expect(labels.map(({ text }) => text)).toEqual(["0dBm", "-60"]); expect(labels[0]).toEqual({ text: "0dBm", x: 84, y: 100 }); - expect(labels[3]?.y).toBe(240); expect(labels[labels.length - 1]?.y).toBe(380); expect(context.font).toBe("20px 'JetBrains Mono', monospace"); expect(fills[0]).toEqual({ color: "#101010", args: [0, 0, 640, 396] }); expect(context.moveTo).toHaveBeenNthCalledWith(1, 96, 100); expect(context.lineTo).toHaveBeenNthCalledWith(1, 104, 100); + + labels.length = 0; + const decimalComposed = composeWaterfallCanvasWithDbAxis({ + waterfallCanvas: waterfall, + dbMin: -55.75, + dbMax: -44.25, + unit: "dBm", + devicePixelRatio: 2, + plotInsets: { left: 80, right: 80, top: 100 }, + referencePlotWidth: 456, + referencePlotHeight: 280, + theme: { background: "#101010", grid: "#303030", text: "#b0b0b0" }, + }); + expect(labels.map(({ text }) => text)).toEqual(["-44.25dBm", "-55.75"]); + expect(labels[1]?.y).toBe(380); + expect(decimalComposed.width).toBe(682); + expect(decimalComposed.height - labels[1]!.y).toBeGreaterThanOrEqual(10); }); it("keeps explicit zero-inset waterfall data intact before blank gutters", () => { diff --git a/test/ts/waterfallFastSnapshot.test.tsx b/test/ts/waterfallFastSnapshot.test.tsx index 4995e36de..972f6fb7f 100644 --- a/test/ts/waterfallFastSnapshot.test.tsx +++ b/test/ts/waterfallFastSnapshot.test.tsx @@ -44,8 +44,8 @@ describe("standalone waterfall fast snapshot", () => { const spectrumGpu = document.createElement("canvas"); const snapshotData = { frequencyRange: { min: 18_000, max: 3_218_000 }, - dbMin: -57, - dbMax: -52, + dbMin: -55.75, + dbMax: -44.25, powerScale: "dBm" as const, vizZoom: 1, vizPanOffset: 0, @@ -75,8 +75,8 @@ describe("standalone waterfall fast snapshot", () => { const labels = (global as any).__CANVAS_CALLS__ .filter((call: any) => call.name === "fillText") .map((call: any) => call.args[0]); - expect(labels).toContain("-52dBm"); - expect(labels).toContain("-57"); + expect(labels).toContain("-44.25dBm"); + expect(labels).toContain("-55.75"); const calls = (global as any).__CANVAS_CALLS__ as Array<{ name: string; @@ -100,17 +100,17 @@ describe("standalone waterfall fast snapshot", () => { 0, sourceWidth, visibleContentHeight, - 52 * dpr, + 73 * dpr, 0, referencePlotWidth, visibleContentHeight, ]); const dbMaxLabel = calls.find( - (call) => call.name === "fillText" && call.args[0] === "-52dBm", + (call) => call.name === "fillText" && call.args[0] === "-44.25dBm", ); const dbMinLabel = calls.find( - (call) => call.name === "fillText" && call.args[0] === "-57", + (call) => call.name === "fillText" && call.args[0] === "-55.75", ); const sourceWaterfallCanvas = plotCopy?.args[0] as HTMLCanvasElement; const fullWidthHeaderCopy = calls.find( @@ -223,8 +223,8 @@ describe("standalone waterfall fast snapshot", () => { const waterfallData = { frequencyRange: { min: 18_000, max: 3_218_000 }, waveform: new Float32Array([-45, -30, -38, -24]), - dbMin: -57, - dbMax: -52, + dbMin: -55.75, + dbMax: -44.25, powerScale: "dBm" as const, vizZoom: 1, vizPanOffset: 0, @@ -294,8 +294,8 @@ describe("standalone waterfall fast snapshot", () => { const labels = (global as any).__CANVAS_CALLS__ .filter((call: any) => call.name === "fillText") .map((call: any) => call.args[0]); - expect(labels).toContain("-52dBm"); - expect(labels).toContain("-57"); + expect(labels).toContain("-44.25dBm"); + expect(labels).toContain("-55.75"); const calls = (global as any).__CANVAS_CALLS__ as Array<{ name: string; args: any[]; From 0f27accae591aa14ef78e99e049060a3e49129e7 Mon Sep 17 00:00:00 2001 From: Ceane <1743355+ceane@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:23:50 -0800 Subject: [PATCH 4/4] Draft: recover RTL streams and clarify device status Keep stale and reload recovery visible, rearm source frames after interruptions, and validate whole-hertz stream tuning. Preserve hardware source choices during stale recovery and align file-placeholder test fixtures. --- src/rs/server/websocket_server/sources.rs | 14 ++- .../streams/sourceModeStreamManager.ts | 14 ++- src/ts/app/routes/pages/SpectrumRoute.tsx | 9 ++ src/ts/features/spectrum/FFTAndWaterfall.tsx | 10 ++- .../model/multiplexStream/lifecycle.ts | 72 +++++++++++++--- .../features/spectrum/sidebar/SourceInput.tsx | 17 ++-- .../redux/middleware/websocketMiddleware.ts | 42 ++++++++- src/ts/validation/schemas.ts | 10 ++- test/ts/FFTAndWaterfall.test.tsx | 63 ++++++++++++++ test/ts/SourceInput.test.tsx | 5 +- test/ts/SpectrumRoute.mockTxPreview.test.ts | 59 ++++++++++--- test/ts/filePlaceholderState.test.ts | 3 +- test/ts/liveSourceLifecycle.test.ts | 86 +++++++++++++++---- test/ts/managedStreamMessageRouting.test.ts | 36 ++++++++ test/ts/spectrumStoreOverrides.test.ts | 1 + 15 files changed, 386 insertions(+), 55 deletions(-) diff --git a/src/rs/server/websocket_server/sources.rs b/src/rs/server/websocket_server/sources.rs index 78fa96992..fb8c11c0f 100644 --- a/src/rs/server/websocket_server/sources.rs +++ b/src/rs/server/websocket_server/sources.rs @@ -203,6 +203,13 @@ mod tx_suite_tests { use serial_test::serial; use std::sync::atomic::Ordering; + #[test] + fn hardware_frame_liveness_expires_after_one_second() { + use std::time::Duration; + assert!(super::is_recent_hardware_frame(Duration::from_millis(999))); + assert!(!super::is_recent_hardware_frame(Duration::from_millis(1000))); + } + #[test] fn prefers_dedicated_rx_and_half_duplex_tx_pair() { let pair = resolve_tx_suite_pair(&[ @@ -396,6 +403,11 @@ fn is_tx_capable_source_kind(kind: &str) -> bool { matches!(kind, "hackrf_one" | "mock_tx") } +// A recent successful read indicates active hardware reception. +fn is_recent_hardware_frame(age: std::time::Duration) -> bool { + age < std::time::Duration::from_secs(1) +} + fn source_status_for_entry( is_active_source: bool, is_paused: bool, @@ -532,7 +544,7 @@ fn build_source_payload( .map(|timestamp| { ( true, - timestamp.elapsed() <= std::time::Duration::from_secs(2), + is_recent_hardware_frame(timestamp.elapsed()), ) }) .unwrap_or((false, false)); diff --git a/src/ts/app/infrastructure/streams/sourceModeStreamManager.ts b/src/ts/app/infrastructure/streams/sourceModeStreamManager.ts index 91df05163..e8458119a 100644 --- a/src/ts/app/infrastructure/streams/sourceModeStreamManager.ts +++ b/src/ts/app/infrastructure/streams/sourceModeStreamManager.ts @@ -1,5 +1,9 @@ import type { IqRawFrameV2 } from "@n-apt/consts/schemas/websocket"; import { evaluateSequenceStep } from "@n-apt/math/sequenceStep"; +import { + CenterFrequencyHzSchema, + RxCenterFrequencyHzSchema, +} from "@n-apt/validation/schemas"; import { STREAM_CONTROL_CONTRACT, type StreamSubscriberContract, @@ -248,7 +252,15 @@ type SourceModeStreamManagerOptions = { const keyFor = ({ sourceId, mode }: StreamKey): string => `${sourceId}\u0000${mode}`; -const cloneOptions = (options: StreamOptions): StreamOptions => ({ ...options }); +const cloneOptions = (options: StreamOptions): StreamOptions => ({ + ...options, + centerFrequencyHz: (options.mode === "rx" + ? RxCenterFrequencyHzSchema + : CenterFrequencyHzSchema + ).parse( + Math.round(options.centerFrequencyHz), + ), +}); const optionsEqual = (left: StreamOptions, right: StreamOptions): boolean => { const leftEntries = Object.entries(left); diff --git a/src/ts/app/routes/pages/SpectrumRoute.tsx b/src/ts/app/routes/pages/SpectrumRoute.tsx index 468a3dfdc..c5045f904 100644 --- a/src/ts/app/routes/pages/SpectrumRoute.tsx +++ b/src/ts/app/routes/pages/SpectrumRoute.tsx @@ -569,6 +569,12 @@ export const SpectrumRoute: React.FC = ({ const hasConnectedOnce = useAppSelector( (state) => state.websocket.hasConnectedOnce, ); + const reconnectAttempts = useAppSelector( + (state) => state.websocket.reconnectAttempts, + ); + const maxReconnectAttempts = useAppSelector( + (state) => state.websocket.maxReconnectAttempts, + ); const txViewerSampleRateHz = useAppSelector( (state) => state.spectrum.txViewerSampleRateHz, ); @@ -908,6 +914,9 @@ export const SpectrumRoute: React.FC = ({ isConnected, connectionStatus, hasConnectedOnce, + reconnectAttempts, + maxReconnectAttempts, + isDevelopment: import.meta.env.DEV, sourceHandoffPending: liveSourceHandoffPending, selectedSourceId: selectedSourceId || null, activeSourceId: activeSourceId || null, diff --git a/src/ts/features/spectrum/FFTAndWaterfall.tsx b/src/ts/features/spectrum/FFTAndWaterfall.tsx index 1bd34202b..0238683b5 100644 --- a/src/ts/features/spectrum/FFTAndWaterfall.tsx +++ b/src/ts/features/spectrum/FFTAndWaterfall.tsx @@ -339,6 +339,12 @@ const FFTAndWaterfall = forwardRef( props.isStandby, sharedAwaitingDeviceData, ]); + const shouldRenderExplicitRecoveryPlaceholder = + sharedPlaceholderState?.kind === "loading" && + typeof sharedPlaceholderState.title === "string" && + sharedPlaceholderState.title.trim().length > 0; + const shouldRenderPlaceholderNow = + shouldShowLoadingPlaceholder || shouldRenderExplicitRecoveryPlaceholder; const visualizerConnectionState = resolveVisualizerConnectionState({ sourceMode, @@ -552,7 +558,7 @@ const FFTAndWaterfall = forwardRef( placeholderErrorReason={placeholderErrorReason} placeholderState={ sharedPlaceholderState?.kind === "loading" && - !shouldShowLoadingPlaceholder + !shouldRenderPlaceholderNow ? undefined : sharedPlaceholderState?.kind === "loading" ? { ...sharedPlaceholderState, paneLabel: "FFT" } @@ -583,7 +589,7 @@ const FFTAndWaterfall = forwardRef( sourceLabel: props.placeholderSourceLabel, } : sharedPlaceholderState?.kind === "loading" && - !shouldShowLoadingPlaceholder + !shouldRenderPlaceholderNow ? undefined : sharedPlaceholderState?.kind === "loading" ? { ...sharedPlaceholderState, paneLabel: "Waterfall" } diff --git a/src/ts/features/spectrum/model/multiplexStream/lifecycle.ts b/src/ts/features/spectrum/model/multiplexStream/lifecycle.ts index bfff3be20..39900bbb6 100644 --- a/src/ts/features/spectrum/model/multiplexStream/lifecycle.ts +++ b/src/ts/features/spectrum/model/multiplexStream/lifecycle.ts @@ -143,7 +143,8 @@ export const resolveLiveDevicePlaceholderState = ({ (hasRenderableCurrentFrame || hasPlayedAtLeastOnce) && deviceState !== "disconnected" && deviceState !== "error" && - deviceState !== "loading" + deviceState !== "loading" && + deviceState !== "stale" ) { return null; } @@ -162,6 +163,7 @@ export const resolveLiveDevicePlaceholderState = ({ message: "Resolve the device error before live I/Q can resume.", }; } + const isStale = deviceState === "stale"; const attempt = typeof loadingAttempt === "number" && Number.isFinite(loadingAttempt) ? Math.max(0, Math.floor(loadingAttempt)) @@ -176,12 +178,12 @@ export const resolveLiveDevicePlaceholderState = ({ kind: "loading", paneLabel: "device", sourceLabel, - message: - deviceState === "stale" - ? "The device is still visible but has not produced a fresh frame yet." - : attempt > 0 - ? `Attempting to restart the device... (${attempt}/${attemptMax || "?"})` - : "The device is restarting; this can take 20-30 seconds for HackRF One.", + title: isStale ? "Device Stale — Reconnecting" : "Device Reconnecting", + message: isStale + ? "The USB plug can be sensitive to movement and may have briefly disconnected. Reconnecting usually takes a few seconds." + : attempt > 0 + ? `Attempting to reconnect the device... (${attempt}/${attemptMax || "?"}). The USB connection usually recovers within a few seconds.` + : "The device is reconnecting; this usually takes a few seconds. Check the USB connection if it does not recover.", }; }; @@ -622,12 +624,16 @@ export const isControlPlaneUnavailable = ({ hasConnectedOnce = false, sourceHandoffPending: _sourceHandoffPending = false, transportPhase = null, + reconnectAttempts = 0, + maxReconnectAttempts = 0, }: { isConnected: boolean; connectionStatus?: string | null; hasConnectedOnce?: boolean; sourceHandoffPending?: boolean; transportPhase?: string | null; + reconnectAttempts?: number; + maxReconnectAttempts?: number; }): boolean => { // An open control socket is never "Server Down". Stream/source failures can // leave connectionStatus sticky at "error" without closing the socket @@ -639,9 +645,16 @@ export const isControlPlaneUnavailable = ({ // that the stream is still open. if (transportPhase === "ready") return false; if (connectionStatus === "error") return true; - // After a live session, softDisconnect / reconnect polling is Server Down. - // First-boot disconnected/connecting keep Loading instead. - if (hasConnectedOnce === true) return true; + // After a live session, allow the middleware's bounded reconnect loop to + // recover a brief server restart. Exhausting attempts means the backend is + // unavailable, while first-boot disconnected/connecting keeps warming. + if ( + hasConnectedOnce === true && + reconnectAttempts >= maxReconnectAttempts && + connectionStatus !== "connected" + ) { + return true; + } return false; }; @@ -673,6 +686,9 @@ export const resolveLiveSourceLifecycle = ({ isConnected = true, connectionStatus = null, hasConnectedOnce = false, + reconnectAttempts = 0, + maxReconnectAttempts = 5, + isDevelopment = false, sourceHandoffPending, isStandby = false, readinessSequence = null, @@ -700,6 +716,9 @@ export const resolveLiveSourceLifecycle = ({ isConnected?: boolean; connectionStatus?: string | null; hasConnectedOnce?: boolean; + reconnectAttempts?: number; + maxReconnectAttempts?: number; + isDevelopment?: boolean; sourceHandoffPending?: boolean; isStandby?: boolean; readinessSequence?: number | null; @@ -757,6 +776,28 @@ export const resolveLiveSourceLifecycle = ({ }); if (!isLive) return result("idle", null); + if ( + isConnected === false && + transportPhase !== "ready" && + hasConnectedOnce && + connectionStatus !== "connected" && + reconnectAttempts < maxReconnectAttempts && + selectedSourceId + ) { + const retryMessage = + reconnectAttempts > 0 + ? `${isDevelopment ? "Hot reload reconnecting" : "Server reconnecting"}… (attempt ${reconnectAttempts}/${maxReconnectAttempts}). Waiting for the device stream.` + : isDevelopment + ? "Hot reload in progress… reconnecting to the device stream." + : "Server reconnecting… restoring the device stream."; + return result("recovering", { + kind: "loading", + sourceLabel: resolvedSourceLabel, + paneLabel: "server", + title: isDevelopment ? "Hot Reload — Reconnecting" : "Server Reconnecting", + message: retryMessage, + }); + } if ( isControlPlaneUnavailable({ isConnected, @@ -764,6 +805,8 @@ export const resolveLiveSourceLifecycle = ({ hasConnectedOnce, sourceHandoffPending: handoffPending, transportPhase, + reconnectAttempts, + maxReconnectAttempts, }) ) { return result("failed", { @@ -801,6 +844,12 @@ export const resolveLiveSourceLifecycle = ({ } if (!selectedSourceId) return result("idle", null); + // A stale selected device is authoritative even when a handoff or old + // readiness metadata remains from its previous stream. + if (deviceStatus === "stale") { + return result("recovering", resolvedDevicePlaceholder); + } + // A source-tagged frame is proof that the selected stream is already // usable, even if the control-plane source commit is still catching up. // Do not hide that frame behind the handoff loader; this is especially @@ -902,6 +951,9 @@ export const useLiveSourceLifecycle = ( input.connectionStatus, input.deviceStatus, input.hasConnectedOnce, + input.reconnectAttempts, + input.maxReconnectAttempts, + input.isDevelopment, input.sourceHandoffPending, input.hasValidFrame, input.isConnected, diff --git a/src/ts/features/spectrum/sidebar/SourceInput.tsx b/src/ts/features/spectrum/sidebar/SourceInput.tsx index d0bcba2ef..bbc63a628 100644 --- a/src/ts/features/spectrum/sidebar/SourceInput.tsx +++ b/src/ts/features/spectrum/sidebar/SourceInput.tsx @@ -491,13 +491,14 @@ export const SourceInput: React.FC = ({ return !["disconnected", "offline", "stale", "error"].includes(label); }; const hasConnectedHardwareSource = sourceDevicesRaw.some( - (device) => !isMockDeviceLocal(device) && isDeviceConnected(device), + (device) => + !isMockDeviceLocal(device) && + (isDeviceConnected(device) || + device.status?.label?.toLowerCase?.() === "stale"), ); - // The mock fallback must stay visible while it is the actual active stream - // (the backend fell back to it after a hardware unplug) so the pill list - // matches what is really on screen. It is dropped whenever a real hardware - // source is present and streaming/transmitting — the mock is then not the - // active fallback, and the "hardware replaces mock" rule applies. + // A streaming mock fallback remains visible only after hardware has actually + // disconnected. A stale hardware source is still recovering, so keep its + // card visible and don't offer mock sources as a substitute. const activeHardwareStreaming = sourceDevicesRaw.some( (device) => !isMockDeviceLocal(device) && @@ -506,7 +507,9 @@ export const SourceInput: React.FC = ({ device.status?.label?.toLowerCase?.() === "transmitting"), ); const activeMockFallbackDevice = - sourceMode === "live" && !activeHardwareStreaming + sourceMode === "live" && + !hasConnectedHardwareSource && + !activeHardwareStreaming ? (sourceDevicesRaw.find( (device) => isMockDeviceLocal(device) && diff --git a/src/ts/redux/middleware/websocketMiddleware.ts b/src/ts/redux/middleware/websocketMiddleware.ts index 1971f6c70..1b898b0c2 100644 --- a/src/ts/redux/middleware/websocketMiddleware.ts +++ b/src/ts/redux/middleware/websocketMiddleware.ts @@ -1357,7 +1357,7 @@ export const buildManagedRxOptions = ( .find((frequency) => Number.isFinite(frequency) && frequency > 0) ?? 0; return { mode: "rx", - centerFrequencyHz, + centerFrequencyHz: Math.round(centerFrequencyHz), sampleRateHz: Number( overrides.sampleRateHz ?? settings.sample_rate ?? @@ -1950,6 +1950,21 @@ export const shouldRequestManagedRxStartupFrame = ({ !hasFrame && !alreadyRequested; +export const shouldResetManagedRxStartupFrameRequest = ({ + sourceId, + status, + startupFrameRequestSourceId, +}: { + sourceId: string; + status: unknown; + startupFrameRequestSourceId: string | null; +}): boolean => + sourceId === startupFrameRequestSourceId && + (status === "stale" || + status === "disconnected" || + status === "loading" || + status === "initializing"); + /** * A paused subscriber that (re)opens its transport has nothing to show: the * pause gate suppressed the stream, and the presentation slot went with the @@ -2395,12 +2410,13 @@ const syncManagedStreamSubscriptions = ( if (managedRxSubscription && rxSourceId) { const latestState = getState().websocket; const latestReadiness = latestState.sourceFrameReadinessByMode?.rx ?? null; + const rxStatus = + latestState.sourceStatuses?.[rxSourceId] ?? desiredRxSource.status; if ( shouldRequestManagedRxStartupFrame({ activeSourceId: latestState.activeSourceId, rxSourceId, - sourceStatus: - latestState.sourceStatuses?.[rxSourceId] ?? desiredRxSource.status, + sourceStatus: rxStatus, hasFrame: latestReadiness?.sourceId === rxSourceId, alreadyRequested: managedRxStartupFrameRequestSourceId === rxSourceId, }) @@ -3513,6 +3529,26 @@ export const processWebSocketMessage = ( sources: nextSources, ...derived, }; + if ( + shouldResetManagedRxStartupFrameRequest({ + sourceId: parsedData.source_id, + status: sourceStatuses[parsedData.source_id], + startupFrameRequestSourceId: managedRxStartupFrameRequestSourceId, + }) + ) { + managedRxStartupFrameRequestSourceId = null; + const readiness = + getState().websocket.sourceFrameReadinessByMode?.rx ?? null; + if (readiness?.sourceId === parsedData.source_id) { + updates.sourceFrameReadinessByMode = { + ...(getState().websocket.sourceFrameReadinessByMode ?? { + rx: null, + tx: null, + }), + rx: null, + }; + } + } if (parsedData.source_id === getState().websocket.activeSourceId) { updates.deviceState = mapSourceStatusToDeviceState(parsedData.status); updates.deviceLoadingReason = diff --git a/src/ts/validation/schemas.ts b/src/ts/validation/schemas.ts index 5286a4e53..a34c91bb9 100644 --- a/src/ts/validation/schemas.ts +++ b/src/ts/validation/schemas.ts @@ -6,6 +6,14 @@ import { z } from "zod"; import type { TrustLevel, ExpectedLatency } from "./types"; +// Rust stream and status messages represent center frequency as u64 hertz. +// JavaScript can represent only safe integers exactly. +export const CenterFrequencyHzSchema = z.number().int().nonnegative(); +// RX stream options are narrowed to u32 by stream_rx_processor_settings. +export const RxCenterFrequencyHzSchema = CenterFrequencyHzSchema.min(1).max( + 4_294_967_295, +); + // Preprocesses null values to undefined so they map properly to optional types rather than null const _nullableToOptional = (schema: T) => z.preprocess((val) => (val === null ? undefined : val), schema.optional()); @@ -548,7 +556,7 @@ export const WebSocketMessageSchema = z.union([ status: z.enum(["standby", "transmitting"]), txDevice: z.string().optional(), serialNumber: z.string().optional(), - centerFrequencyHz: z.number().optional(), + centerFrequencyHz: CenterFrequencyHzSchema.optional(), bandwidthHz: z.number().optional(), sampleRateHz: z.number().optional(), ifftSize: z.number().int().optional(), diff --git a/test/ts/FFTAndWaterfall.test.tsx b/test/ts/FFTAndWaterfall.test.tsx index 80339f97e..a890aef67 100644 --- a/test/ts/FFTAndWaterfall.test.tsx +++ b/test/ts/FFTAndWaterfall.test.tsx @@ -818,6 +818,69 @@ describe("FFTAndWaterfall", () => { jest.useRealTimers(); }); + it("shows explicit stale-device recovery copy immediately during the handoff grace", () => { + jest.useFakeTimers(); + const dataRef: { + current: { source_id: string; iq_data: Uint8Array } | null; + } = { + current: { + source_id: "rtl-sdr-1", + iq_data: new Uint8Array([128, 129]), + }, + }; + const props = { + dataRef, + frequencyRange: { min: 100, max: 101 }, + centerFrequencyHz: 100_500_000, + activeSignalArea: "A", + isPaused: false, + snapshotGridPreference: true, + loadingPlaceholderDelayMs: 1_000, + }; + const { rerender } = render(); + act(() => { + const initialProps = + fftCanvasMock.mock.calls[fftCanvasMock.mock.calls.length - 1]?.[0]; + initialProps?.onRenderableFrameChange?.(true); + initialProps?.onCanvasLoadingChange?.(false); + }); + + dataRef.current = null; + const reconnectingPlaceholder = { + kind: "loading" as const, + sourceLabel: "RTL-SDR v4", + paneLabel: "device", + title: "Device Stale — Reconnecting", + message: "The USB plug may have briefly disconnected.", + }; + rerender( + , + ); + + const fftProps = + fftCanvasMock.mock.calls[fftCanvasMock.mock.calls.length - 1]?.[0]; + const waterfallProps = + waterfallCanvasMock.mock.calls[ + waterfallCanvasMock.mock.calls.length - 1 + ]?.[0]; + expect(fftProps?.placeholderState).toMatchObject({ + ...reconnectingPlaceholder, + paneLabel: "FFT", + }); + expect(waterfallProps?.placeholderState).toMatchObject( + { ...reconnectingPlaceholder, paneLabel: "Waterfall" }, + ); + jest.useRealTimers(); + }); + it("passes Tx slider props on the first render when Redux says Tx is visible", () => { mockedSpectrumState = { showTxSlider: true, diff --git a/test/ts/SourceInput.test.tsx b/test/ts/SourceInput.test.tsx index c540fb49c..a97f9c5e4 100644 --- a/test/ts/SourceInput.test.tsx +++ b/test/ts/SourceInput.test.tsx @@ -1215,7 +1215,7 @@ describe("SourceInput", () => { ).not.toBeInTheDocument(); }); - it("keeps the mock fallback pill visible while it is the streaming active source", () => { + it("hides mock fallback devices while stale hardware is recovering", () => { render( { , ); - expect(screen.getByText("Mock APT SDR")).toBeInTheDocument(); + expect(screen.getByText("RTL-SDR v4")).toBeInTheDocument(); + expect(screen.queryByText("Mock APT SDR")).not.toBeInTheDocument(); }); it("switches half-duplex devices into Tx standby with one click", () => { diff --git a/test/ts/SpectrumRoute.mockTxPreview.test.ts b/test/ts/SpectrumRoute.mockTxPreview.test.ts index 445c6d4d1..083f6f288 100644 --- a/test/ts/SpectrumRoute.mockTxPreview.test.ts +++ b/test/ts/SpectrumRoute.mockTxPreview.test.ts @@ -224,17 +224,20 @@ describe("resolveMockTxMonitorViewSampleRateHz", () => { describe("resolveLiveDevicePlaceholderState", () => { it("dismisses a stale placeholder when current-source I/Q is already renderable", () => { - // `stale` with a genuinely valid current-source frame is a live stream: - // the placeholder is dismissed. - expect( - resolveLiveDevicePlaceholderState({ - deviceState: "stale", - sourceLabel: "RTL-SDR v4", - hasRenderableCurrentFrame: true, - } as Parameters[0] & { - hasRenderableCurrentFrame: boolean; - }), - ).toBeNull(); + const placeholder = resolveLiveDevicePlaceholderState({ + deviceState: "stale", + sourceLabel: "RTL-SDR v4", + hasRenderableCurrentFrame: true, + } as Parameters[0] & { + hasRenderableCurrentFrame: boolean; + }); + + expect(placeholder).toMatchObject({ + kind: "loading", + title: "Device Stale — Reconnecting", + message: + "The USB plug can be sensitive to movement and may have briefly disconnected. Reconnecting usually takes a few seconds.", + }); }); it("keeps a loading placeholder even when a renderable frame exists", () => { @@ -252,6 +255,20 @@ describe("resolveLiveDevicePlaceholderState", () => { ).toMatchObject({ kind: "loading" }); }); + it("uses a device reconnect title instead of a pane-specific loading title", () => { + expect( + resolveLiveDevicePlaceholderState({ + deviceState: "loading", + sourceLabel: "RTL-SDR v4", + }), + ).toMatchObject({ + kind: "loading", + title: "Device Reconnecting", + message: + "The device is reconnecting; this usually takes a few seconds. Check the USB connection if it does not recover.", + }); + }); + it("keeps an explicit disconnect blocking even when a frame is buffered", () => { expect( resolveLiveDevicePlaceholderState({ @@ -286,7 +303,25 @@ describe("resolveLiveDevicePlaceholderState", () => { kind: "loading", sourceLabel: "RTL-SDR v4", paneLabel: "device", - message: "Attempting to restart the device... (1/2)", + title: "Device Reconnecting", + message: + "Attempting to reconnect the device... (1/2). The USB connection usually recovers within a few seconds.", + }); + }); + + it("keeps the stale-device explanation visible during automatic restart", () => { + expect( + resolveLiveDevicePlaceholderState({ + deviceState: "stale", + sourceLabel: "RTL-SDR v4", + loadingAttempt: 1, + loadingAttemptMax: 2, + }), + ).toMatchObject({ + kind: "loading", + title: "Device Stale — Reconnecting", + message: + "The USB plug can be sensitive to movement and may have briefly disconnected. Reconnecting usually takes a few seconds.", }); }); diff --git a/test/ts/filePlaceholderState.test.ts b/test/ts/filePlaceholderState.test.ts index 1285a3453..863beb64e 100644 --- a/test/ts/filePlaceholderState.test.ts +++ b/test/ts/filePlaceholderState.test.ts @@ -61,7 +61,8 @@ describe("getFilePlaceholderState", () => { ).toEqual({ kind: "error", reason: "File processing error", - title: "File processing error", + title: "File processing failed", + message: "invalid file header", }); }); }); diff --git a/test/ts/liveSourceLifecycle.test.ts b/test/ts/liveSourceLifecycle.test.ts index 5cec879a3..13eea03cf 100644 --- a/test/ts/liveSourceLifecycle.test.ts +++ b/test/ts/liveSourceLifecycle.test.ts @@ -32,6 +32,23 @@ const handoffPlaceholder = { }; describe("resolveLiveSourceLifecycle", () => { + test("shows stale device recovery over handoff and old frame readiness", () => { + const lifecycle = resolveLiveSourceLifecycle({ + selectedSourceId: "rtl-sdr-1", + activeSourceId: "mock-apt", + transportSourceId: "rtl-sdr-1", + transportPhase: "warming", + hasValidFrame: true, + deviceStatus: "stale", + sourceLabel: "RTL-SDR v4", + sourceHandoffPending: true, + hasPlayedAtLeastOnce: true, + }); + expect(lifecycle).toMatchObject({ + phase: "recovering", + placeholder: { kind: "loading", title: "Device Stale — Reconnecting" }, + }); + }); test("invalidates old renderability while the selected transport is warming", () => { expect( shouldInvalidateLiveFrameStateForTransport({ @@ -689,7 +706,7 @@ describe("resolveLiveSourceLifecycle", () => { ).toBe(true); }); - test("keeps Server Down when disconnected even if stale warming metadata remains", () => { + test("shows recovery while reconnect attempts remain despite stale warming metadata", () => { expect( isControlPlaneUnavailable({ isConnected: false, @@ -697,11 +714,13 @@ describe("resolveLiveSourceLifecycle", () => { hasConnectedOnce: true, sourceHandoffPending: true, transportPhase: "warming", + reconnectAttempts: 0, + maxReconnectAttempts: 5, }), - ).toBe(true); + ).toBe(false); }); - test("still reports Server Down after a live session is lost with no handoff", () => { + test("reports Server Down after reconnect attempts are exhausted", () => { expect( isControlPlaneUnavailable({ isConnected: false, @@ -709,14 +728,13 @@ describe("resolveLiveSourceLifecycle", () => { hasConnectedOnce: true, sourceHandoffPending: false, transportPhase: "idle", + reconnectAttempts: 5, + maxReconnectAttempts: 5, }), ).toBe(true); }); - test("keeps Server Down during reconnect polling after a live session", () => { - // Middleware softDisconnect → Server Down, then backoff setReconnecting / - // setConnecting. Those attempts must not flash Loading while the backend - // is still dead. + test("keeps recovery visible during reconnect polling while attempts remain", () => { expect( isControlPlaneUnavailable({ isConnected: false, @@ -724,8 +742,10 @@ describe("resolveLiveSourceLifecycle", () => { hasConnectedOnce: true, sourceHandoffPending: false, transportPhase: "idle", + reconnectAttempts: 1, + maxReconnectAttempts: 5, }), - ).toBe(true); + ).toBe(false); expect( isControlPlaneUnavailable({ @@ -734,8 +754,10 @@ describe("resolveLiveSourceLifecycle", () => { hasConnectedOnce: true, sourceHandoffPending: false, transportPhase: "idle", + reconnectAttempts: 1, + maxReconnectAttempts: 5, }), - ).toBe(true); + ).toBe(false); expect( resolveLiveSourceLifecycle({ @@ -748,12 +770,41 @@ describe("resolveLiveSourceLifecycle", () => { isConnected: false, connectionStatus: "reconnecting", hasConnectedOnce: true, + reconnectAttempts: 1, + maxReconnectAttempts: 5, handoffPlaceholder, }).phase, - ).toBe("failed"); + ).toBe("recovering"); + }); + + test("shows a reconnecting placeholder during the server reload grace period", () => { + const lifecycle = resolveLiveSourceLifecycle({ + selectedSourceId: "rtl-sdr-1", + activeSourceId: "rtl-sdr-1", + transportSourceId: null, + transportPhase: "idle", + hasValidFrame: false, + deviceStatus: "disconnected", + isConnected: false, + connectionStatus: "disconnected", + hasConnectedOnce: true, + reconnectAttempts: 0, + maxReconnectAttempts: 5, + isDevelopment: true, + handoffPlaceholder, + }); + + expect(lifecycle).toMatchObject({ + phase: "recovering", + placeholder: { + kind: "loading", + sourceLabel: "rtl-sdr-1", + message: "Hot reload in progress… reconnecting to the device stream.", + }, + }); }); - test("shows Server Down after a socket close invalidates its ready stream", () => { + test("shows reconnecting after a socket close invalidates its ready stream", () => { const connected = websocketReducer(undefined, setConnected()); const withReadyStream = websocketReducer( connected, @@ -782,8 +833,8 @@ describe("resolveLiveSourceLifecycle", () => { }); expect(lifecycle).toMatchObject({ - phase: "failed", - placeholder: { kind: "error", reason: "Server down" }, + phase: "recovering", + placeholder: { kind: "loading", paneLabel: "server" }, }); }); @@ -798,6 +849,8 @@ describe("resolveLiveSourceLifecycle", () => { isConnected: false, connectionStatus: "disconnected", hasConnectedOnce: true, + reconnectAttempts: 5, + maxReconnectAttempts: 5, handoffPlaceholder, }); @@ -876,7 +929,7 @@ describe("resolveLiveSourceLifecycle", () => { }); }); - test("lets a valid current-source frame override lagging recovery status", () => { + test("keeps stale recovery visible while an earlier frame remains buffered", () => { expect( resolveLiveSourceLifecycle({ selectedSourceId: "rtl-sdr-v4", @@ -887,7 +940,10 @@ describe("resolveLiveSourceLifecycle", () => { deviceStatus: "stale", handoffPlaceholder, }), - ).toMatchObject({ phase: "ready", placeholder: null }); + ).toMatchObject({ + phase: "recovering", + placeholder: { title: "Device Stale — Reconnecting" }, + }); }); test("loading wins over a stale renderable frame so a loading HackRF never shows a blank canvas", () => { diff --git a/test/ts/managedStreamMessageRouting.test.ts b/test/ts/managedStreamMessageRouting.test.ts index c748491a4..d6c674293 100644 --- a/test/ts/managedStreamMessageRouting.test.ts +++ b/test/ts/managedStreamMessageRouting.test.ts @@ -5,8 +5,10 @@ import websocketMiddleware, { buildManagedRxOptions, getManagedStreamDebugSnapshot, resetWebSocketMiddlewareState, + shouldResetManagedRxStartupFrameRequest, } from "@n-apt/redux/middleware/websocketMiddleware"; import { waitFor } from "@testing-library/react"; +import { CenterFrequencyHzSchema, RxCenterFrequencyHzSchema } from "@n-apt/validation/schemas"; type TestSocket = { readyState: number; @@ -29,6 +31,25 @@ const makeSocket = (): TestSocket => ({ }); describe("managed stream message routing", () => { + it("validates center frequency as a safe, nonnegative whole number of hertz", () => { + expect(CenterFrequencyHzSchema.safeParse(1_618_001).success).toBe(true); + expect(CenterFrequencyHzSchema.safeParse(1_618_000.5).success).toBe(false); + expect(CenterFrequencyHzSchema.safeParse(-1).success).toBe(false); + expect(CenterFrequencyHzSchema.safeParse(Number.MAX_SAFE_INTEGER + 1).success).toBe(false); + expect(RxCenterFrequencyHzSchema.safeParse(4_294_967_295).success).toBe(true); + expect(RxCenterFrequencyHzSchema.safeParse(4_294_967_296).success).toBe(false); + expect(RxCenterFrequencyHzSchema.safeParse(0).success).toBe(false); + }); + it("rearms the RX startup frame request when its source becomes stale", () => { + expect( + shouldResetManagedRxStartupFrameRequest({ + sourceId: "rtl-sdr-1", + status: "stale", + startupFrameRequestSourceId: "rtl-sdr-1", + }), + ).toBe(true); + }); + let sockets: TestSocket[]; let store: ReturnType; @@ -135,6 +156,7 @@ describe("managed stream message routing", () => { return streamSocket; }; + it("builds initial RTL-SDR options at Channel A's displayed center", () => { const rtlSource = { id: "rtl-sdr-0", @@ -224,6 +246,20 @@ describe("managed stream message routing", () => { expect(mirroredNegativeOptions.centerFrequencyHz).toBe(382_000); }); + it("sends whole hertz when the displayed center is fractional", () => { + const source = { + id: "rtl-sdr-1", + kind: "rtl_sdr", + capability: "rx", + sdr: { settings: { sample_rate: 3_200_000, fft_size: 2048 } }, + } as any; + const options = buildManagedRxOptions({ + spectrum: { frequencyRange: { min: 18_000, max: 3_218_001 }, vizZoom: 1 }, + settings: {}, + }, source); + expect(options.centerFrequencyHz).toBe(1_618_001); + }); + it("sends RX option changes on the managed stream socket", async () => { const streamSocket = await openManagedRxStream(); diff --git a/test/ts/spectrumStoreOverrides.test.ts b/test/ts/spectrumStoreOverrides.test.ts index bf2ef1fcb..51210308c 100644 --- a/test/ts/spectrumStoreOverrides.test.ts +++ b/test/ts/spectrumStoreOverrides.test.ts @@ -13,6 +13,7 @@ describe("applyWaterfallStateOverrides", () => { selectedFiles: [{ id: "file-1", name: "capture.napt" }], snapshotGridPreference: false, stitchStatus: "processing", + stitchState: "loading", stitchTrigger: 4, stitchSourceSettings: { gain: 22, ppm: 3 }, isStitchPaused: true,