Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/rs/server/websocket_server/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&[
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
42 changes: 26 additions & 16 deletions src/ts/app/infrastructure/io/filePlaceholderState.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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",
Expand All @@ -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.",
};
}

Expand Down
14 changes: 13 additions & 1 deletion src/ts/app/infrastructure/streams/sourceModeStreamManager.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion src/ts/app/routes/pages/SpectrumRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -63,6 +64,7 @@ import {
setFrequencyRange,
setSourceViewFrequencyRange,
setStitchStatus,
setStitchState,
resetWaterfallCleared,
setVizZoom as setVizZoomAction,
setVizZoomFloor as setVizZoomFloorAction,
Expand Down Expand Up @@ -567,6 +569,12 @@ export const SpectrumRoute: React.FC<SpectrumRouteProps> = ({
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,
);
Expand Down Expand Up @@ -906,6 +914,9 @@ export const SpectrumRoute: React.FC<SpectrumRouteProps> = ({
isConnected,
connectionStatus,
hasConnectedOnce,
reconnectAttempts,
maxReconnectAttempts,
isDevelopment: import.meta.env.DEV,
sourceHandoffPending: liveSourceHandoffPending,
selectedSourceId: selectedSourceId || null,
activeSourceId: activeSourceId || null,
Expand Down Expand Up @@ -1032,7 +1043,10 @@ export const SpectrumRoute: React.FC<SpectrumRouteProps> = ({
[],
);
const handleStitchStatusChange = useCallback(
(status: string) => reduxDispatch(setStitchStatus(status)),
(status: string, stitchState: StitchState) => {
reduxDispatch(setStitchStatus(status));
reduxDispatch(setStitchState(stitchState));
},
[reduxDispatch],
);
const handleNoopSnapshot = useCallback(() => {}, []);
Expand Down
4 changes: 3 additions & 1 deletion src/ts/features/capture/DemodFilePlaybackBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
});
Expand Down
4 changes: 4 additions & 0 deletions src/ts/features/demodulation/react-flow/nodes/FFTNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ const FFTNodeComponent: React.FC<FFTNodeProps> = ({ 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,
);
Expand Down Expand Up @@ -521,6 +524,7 @@ const FFTNodeComponent: React.FC<FFTNodeProps> = ({ id, data }) => {
sourceMode,
selectedFilesCount: selectedFiles.length,
stitchStatus,
stitchState,
hasRenderableFrame: Boolean(
(frame as any)?.waveform?.length ||
(frame as any)?.iq_data?.length ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,9 @@ const WaterfallNodeComponent: React.FC<WaterfallNodeProps> = ({ data }) => {
const stitchStatus = useAppSelector(
(state) => state.waterfall?.stitchStatus ?? "",
);
const stitchState = useAppSelector(
(state) => state.waterfall?.stitchState ?? "idle",
);
const stitchTrigger = useAppSelector(
(state) => state.waterfall?.stitchTrigger ?? 0,
);
Expand Down Expand Up @@ -832,6 +835,7 @@ const WaterfallNodeComponent: React.FC<WaterfallNodeProps> = ({ data }) => {
sourceMode,
selectedFilesCount: selectedFiles.length,
stitchStatus,
stitchState,
hasRenderableFrame: Boolean(waveform?.length),
});
const isPaused =
Expand Down
10 changes: 8 additions & 2 deletions src/ts/features/spectrum/FFTAndWaterfall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ const FFTAndWaterfall = forwardRef<FFTCanvasHandle, FFTAndWaterfallProps>(
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,
Expand Down Expand Up @@ -552,7 +558,7 @@ const FFTAndWaterfall = forwardRef<FFTCanvasHandle, FFTAndWaterfallProps>(
placeholderErrorReason={placeholderErrorReason}
placeholderState={
sharedPlaceholderState?.kind === "loading" &&
!shouldShowLoadingPlaceholder
!shouldRenderPlaceholderNow
? undefined
: sharedPlaceholderState?.kind === "loading"
? { ...sharedPlaceholderState, paneLabel: "FFT" }
Expand Down Expand Up @@ -583,7 +589,7 @@ const FFTAndWaterfall = forwardRef<FFTCanvasHandle, FFTAndWaterfallProps>(
sourceLabel: props.placeholderSourceLabel,
}
: sharedPlaceholderState?.kind === "loading" &&
!shouldShowLoadingPlaceholder
!shouldRenderPlaceholderNow
? undefined
: sharedPlaceholderState?.kind === "loading"
? { ...sharedPlaceholderState, paneLabel: "Waterfall" }
Expand Down
3 changes: 2 additions & 1 deletion src/ts/features/spectrum/FFTPlaybackCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
26 changes: 16 additions & 10 deletions src/ts/features/spectrum/hooks/useStitchingLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
Expand Down Expand Up @@ -236,6 +237,7 @@ export const useStitchingLogic = ({
setStitchStatus(
progress.status ||
`Loading ${progress.current}/${progress.total} files...`,
"loading",
);
},
aesKeyRef.current,
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
Expand Down
Loading
Loading