1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import FilterListIcon from '@mui/icons-material/FilterList';
5import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
6import SaveAltIcon from '@mui/icons-material/SaveAlt';
7import StopCircleIcon from '@mui/icons-material/StopCircle';
29} from '@mui/material';
30import { invoke } from '@tauri-apps/api/core';
31import { listen } from '@tauri-apps/api/event';
32import { dirname } from '@tauri-apps/api/path';
40import { useScenarioStore } from '@/stores/scenarioStore';
41import { getBlockingFmcwValidationMessage } from '@/stores/scenarioStore/fmcwValidation';
43 normalizeSimulationOutputMetadata,
44 type RawSimulationOutputMetadata,
45} from '@/stores/simulationProgressStore';
47 deriveExpectedVita49Streams,
48 mergeVita49StreamRows,
49 toVita49BackendConfig,
50 useVita49StreamingStore,
51 type Vita49PacketTraceEvent,
52 type Vita49StreamStatsEvent,
53 type Vita49TelemetryPoll,
56} from '@/stores/vita49StreamingStore';
58const TELEMETRY_POLL_INTERVAL_MS = 100;
59const TELEMETRY_POLL_ERROR_INTERVAL_MS = 250;
60const TELEMETRY_POLL_PACKET_LIMIT = 1000;
61const PACKET_TRACE_TABLE_HEIGHT = 420;
62const PACKET_TRACE_ROW_HEIGHT = 36;
63const PACKET_TRACE_OVERSCAN = 8;
65const formatMetric = (value: number | null | undefined) =>
66 value === null || value === undefined
68 : value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
70const formatExact = (value: string | number | null | undefined) =>
71 value === null || value === undefined ? '-' : String(value);
73const formatSeconds = (value: number | null | undefined) =>
74 value === null || value === undefined
76 : `${value.toLocaleString(undefined, { maximumSignificantDigits: 9 })} s`;
78const formatVita49Timestamp = (
79 timestamp: Vita49Timestamp | null | undefined
85 const second = Number(timestamp.integer_seconds);
86 if (!Number.isFinite(second)) {
90 const base = new Date(second * 1000).toISOString().replace('.000Z', '');
91 const fractionalPicoseconds = Math.trunc(timestamp.fractional_picoseconds)
95 return fractionalPicoseconds
96 ? `${base}.${fractionalPicoseconds}Z`
100const formatSimulationSpan = (
101 start: number | null | undefined,
102 end: number | null | undefined
104 const first = formatSeconds(start);
105 const last = formatSeconds(end);
106 return first === '-' && last === '-' ? '-' : `${first} - ${last}`;
109const formatTimestampSpan = (
110 start: Vita49Timestamp | null | undefined,
111 end: Vita49Timestamp | null | undefined
113 const first = formatVita49Timestamp(start);
114 const last = formatVita49Timestamp(end);
115 return first === '-' && last === '-' ? '-' : `${first} - ${last}`;
118const formatStreamId = (streamId: number | null | undefined) =>
119 streamId === null || streamId === undefined
121 : `0x${streamId.toString(16).toUpperCase().padStart(8, '0')}`;
123export const Vita49StreamingView = React.memo(function Vita49StreamingView() {
124 const config = useVita49StreamingStore((state) => state.config);
125 const runState = useVita49StreamingStore((state) => state.runState);
126 const expectedStreams = useVita49StreamingStore(
127 (state) => state.expectedStreams
129 const streamStats = useVita49StreamingStore((state) => state.streamStats);
130 const packetTrace = useVita49StreamingStore((state) => state.packetTrace);
131 const omittedPacketTraceEvents = useVita49StreamingStore(
132 (state) => state.omittedPacketTraceEvents
134 const finalVita49Metadata = useVita49StreamingStore(
135 (state) => state.finalVita49Metadata
137 const error = useVita49StreamingStore((state) => state.error);
138 const setConfig = useVita49StreamingStore((state) => state.setConfig);
139 const startRun = useVita49StreamingStore((state) => state.startRun);
140 const markStopping = useVita49StreamingStore((state) => state.markStopping);
141 const markDraining = useVita49StreamingStore((state) => state.markDraining);
142 const setStreamStats = useVita49StreamingStore(
143 (state) => state.setStreamStats
145 const appendPacketBatch = useVita49StreamingStore(
146 (state) => state.appendPacketBatch
148 const completeRun = useVita49StreamingStore((state) => state.completeRun);
149 const cancelRun = useVita49StreamingStore((state) => state.cancelRun);
150 const failRun = useVita49StreamingStore((state) => state.failRun);
151 const showError = useScenarioStore((state) => state.showError);
152 const showWarning = useScenarioStore((state) => state.showWarning);
153 const showSuccess = useScenarioStore((state) => state.showSuccess);
154 const scenarioFilePath = useScenarioStore(
155 (state) => state.scenarioFilePath
157 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
159 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
162 const [streamFilter, setStreamFilter] = useState('all');
163 const [packetKindFilter, setPacketKindFilter] = useState('all');
164 const [droppedOnly, setDroppedOnly] = useState(false);
165 const [overRangeOnly, setOverRangeOnly] = useState(false);
166 const [sampleLossOnly, setSampleLossOnly] = useState(false);
167 const [packetTraceScrollTop, setPacketTraceScrollTop] = useState(0);
168 const packetTraceContainerRef = useRef<HTMLDivElement | null>(null);
169 const pendingTelemetryRef = useRef<{
170 stats: Vita49StreamStatsEvent | null;
171 packets: Vita49PacketTraceEvent[];
172 omittedPacketTraceEvents: number;
176 omittedPacketTraceEvents: 0,
178 const telemetryFlushFrameRef = useRef<number | null>(null);
180 runState === 'running' ||
181 runState === 'stopping' ||
182 runState === 'draining';
183 const configErrors = validateVita49Config(config);
185 const appendTelemetry = useCallback(
186 (telemetry: Vita49TelemetryPoll) => {
187 if (telemetry.stats) {
188 setStreamStats(telemetry.stats);
191 telemetry.packets.length > 0 ||
192 telemetry.omitted_packet_trace_events > 0
196 telemetry.omitted_packet_trace_events
200 [appendPacketBatch, setStreamStats]
203 const flushPendingTelemetry = useCallback(() => {
204 if (telemetryFlushFrameRef.current !== null) {
205 window.cancelAnimationFrame(telemetryFlushFrameRef.current);
206 telemetryFlushFrameRef.current = null;
209 const pending = pendingTelemetryRef.current;
210 pendingTelemetryRef.current = {
213 omittedPacketTraceEvents: 0,
217 setStreamStats(pending.stats);
220 pending.packets.length > 0 ||
221 pending.omittedPacketTraceEvents > 0
225 pending.omittedPacketTraceEvents
228 }, [appendPacketBatch, setStreamStats]);
230 const drainAvailableTelemetry = useCallback(async () => {
231 flushPendingTelemetry();
234 const telemetry = await invoke<Vita49TelemetryPoll>(
235 'poll_vita49_telemetry',
236 { maxPackets: TELEMETRY_POLL_PACKET_LIMIT }
238 appendTelemetry(telemetry);
239 hasMore = telemetry.has_more;
241 flushPendingTelemetry();
242 }, [appendTelemetry, flushPendingTelemetry]);
246 const unlisteners = Promise.all([
247 listen<string>('vita49-output-metadata', (event) => {
249 const metadata = normalizeSimulationOutputMetadata(
250 JSON.parse(event.payload) as RawSimulationOutputMetadata
252 void drainAvailableTelemetry().finally(() => {
253 if (active) completeRun(metadata);
256 listen<string>('vita49-stream-complete', (event) => {
258 const metadata = normalizeSimulationOutputMetadata(
259 JSON.parse(event.payload) as RawSimulationOutputMetadata
261 void drainAvailableTelemetry().finally(() => {
262 if (active) completeRun(metadata);
265 listen<string>('vita49-stream-draining', () => {
269 listen<string>('vita49-stream-cancelled', (event) => {
271 const metadata = normalizeSimulationOutputMetadata(
272 JSON.parse(event.payload) as RawSimulationOutputMetadata
274 void drainAvailableTelemetry().finally(() => {
275 if (active) cancelRun(metadata);
278 listen<string>('vita49-stream-error', (event) => {
280 failRun(event.payload);
281 showError(`VITA49 streaming failed: ${event.payload}`);
287 unlisteners.then((listeners) =>
288 listeners.forEach((unlisten) => unlisten())
294 drainAvailableTelemetry,
305 let cancelled = false;
306 let pollTimer: ReturnType<typeof setTimeout> | null = null;
308 const scheduleFlush = () => {
309 if (telemetryFlushFrameRef.current !== null) {
312 telemetryFlushFrameRef.current = window.requestAnimationFrame(
313 flushPendingTelemetry
317 const queueTelemetry = (telemetry: Vita49TelemetryPoll) => {
318 const pending = pendingTelemetryRef.current;
319 pending.stats = telemetry.stats ?? pending.stats;
320 pending.packets.push(...telemetry.packets);
321 pending.omittedPacketTraceEvents +=
322 telemetry.omitted_packet_trace_events;
326 const pollTelemetry = async () => {
334 const telemetry = await invoke<Vita49TelemetryPoll>(
335 'poll_vita49_telemetry',
336 { maxPackets: TELEMETRY_POLL_PACKET_LIMIT }
338 queueTelemetry(telemetry);
339 hasMore = telemetry.has_more;
340 } while (!cancelled && hasMore);
343 pollTimer = setTimeout(
345 TELEMETRY_POLL_INTERVAL_MS
349 console.error('Failed to poll VITA49 telemetry:', err);
351 pollTimer = setTimeout(
353 TELEMETRY_POLL_ERROR_INTERVAL_MS
359 void pollTelemetry();
363 if (pollTimer !== null) {
364 clearTimeout(pollTimer);
366 flushPendingTelemetry();
368 }, [flushPendingTelemetry, isRunning]);
370 const streamRows = useMemo(
371 () => mergeVita49StreamRows(expectedStreams, streamStats),
372 [expectedStreams, streamStats]
375 const aggregate = useMemo(
379 packets: acc.packets + row.packetsEmitted,
380 samples: acc.samples + row.samplesEmitted,
381 drops: acc.drops + row.packetsDropped,
382 late: acc.late + row.latePacketCount,
383 overRange: acc.overRange + row.overRangeCount,
384 context: acc.context + row.contextPackets,
398 const streamIdOptions = useMemo(() => {
399 const ids = new Set<number>();
400 for (const row of streamRows) {
401 if (row.streamId !== null) ids.add(row.streamId);
403 for (const packet of packetTrace) {
404 if (packet.stream_id) ids.add(packet.stream_id);
406 return Array.from(ids).sort((a, b) => a - b);
407 }, [packetTrace, streamRows]);
409 const filteredPackets = useMemo(
411 packetTrace.filter((packet) => {
413 streamFilter !== 'all' &&
414 packet.stream_id !== Number(streamFilter)
418 if (packetKindFilter === 'data' && !packet.data_packet) {
421 if (packetKindFilter === 'context' && !packet.context_packet) {
424 if (droppedOnly && !packet.dropped) return false;
425 if (overRangeOnly && !packet.over_range) return false;
426 if (sampleLossOnly && !packet.sample_loss) return false;
440 setPacketTraceScrollTop(0);
441 if (packetTraceContainerRef.current) {
442 packetTraceContainerRef.current.scrollTop = 0;
452 const packetTraceWindow = useMemo(() => {
453 const rawStart = Math.max(
455 Math.floor(packetTraceScrollTop / PACKET_TRACE_ROW_HEIGHT) -
456 PACKET_TRACE_OVERSCAN
458 const start = Math.min(filteredPackets.length, rawStart);
459 const end = Math.min(
460 filteredPackets.length,
462 (packetTraceScrollTop + PACKET_TRACE_TABLE_HEIGHT) /
463 PACKET_TRACE_ROW_HEIGHT
464 ) + PACKET_TRACE_OVERSCAN
469 packets: filteredPackets.slice(start, end),
470 topSpacerHeight: start * PACKET_TRACE_ROW_HEIGHT,
472 Math.max(0, filteredPackets.length - end) *
473 PACKET_TRACE_ROW_HEIGHT,
475 }, [filteredPackets, packetTraceScrollTop]);
477 const getEffectiveOutputDir = async () => {
478 if (outputDirectory) return outputDirectory;
479 if (scenarioFilePath) {
480 return dirname(scenarioFilePath);
485 const handleStart = async () => {
486 const scenarioState = useScenarioStore.getState();
487 const validationMessage =
488 getBlockingFmcwValidationMessage(scenarioState);
489 if (validationMessage) {
490 showError(`FMCW validation failed: ${validationMessage}`);
494 const errors = validateVita49Config(config);
495 if (errors.length > 0) {
496 showError(errors.join(' '));
500 const expected = deriveExpectedVita49Streams(scenarioState);
501 if (expected.length === 0) {
502 showWarning('No receiver streams are configured.');
505 setMetadataExportPath(null);
508 await invoke('set_output_directory', {
509 dir: await getEffectiveOutputDir(),
511 await scenarioState.syncBackend();
512 await invoke('start_vita49_stream', {
513 config: toVita49BackendConfig(config),
516 const message = err instanceof Error ? err.message : String(err);
518 showError(`Failed to start VITA49 streaming: ${message}`);
522 const handleStop = async () => {
525 await invoke('stop_simulation');
527 const message = err instanceof Error ? err.message : String(err);
528 showError(`Failed to stop simulation: ${message}`);
532 const exportMetadataJson = async () => {
534 const outputPath = await invoke<string>(
535 'export_output_metadata_json'
537 setMetadataExportPath(outputPath);
538 showSuccess(`Metadata JSON saved to ${outputPath}`);
540 const message = err instanceof Error ? err.message : String(err);
541 showError(`Failed to export metadata JSON: ${message}`);
546 <Box sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
553 <Typography variant="h4">VITA49 Streams</Typography>
556 color={isRunning ? 'primary' : 'default'}
561 <Alert severity="error" sx={{ mb: 2 }}>
566 <Grid container spacing={2} sx={{ mb: 2 }}>
567 <Grid size={{ xs: 12, lg: 4 }}>
568 <Paper variant="outlined" sx={{ p: 2, height: '100%' }}>
569 <Typography variant="h6" sx={{ mb: 2 }}>
579 setConfig({ host: event.target.value })
590 port: Number(event.target.value),
598 value={config.fullscale}
602 fullscale: Number(event.target.value),
606 <FormControl size="small">
607 <InputLabel id="vita49-epoch-mode-label">
611 labelId="vita49-epoch-mode-label"
613 value={config.epochMode}
617 epochMode: event.target
618 .value as typeof config.epochMode,
622 <MenuItem value="auto">Auto</MenuItem>
623 <MenuItem value="fixed">Fixed</MenuItem>
626 {config.epochMode === 'fixed' && (
630 value={config.epochUnixNanoseconds}
634 epochUnixNanoseconds:
641 label="Max UDP payload"
644 value={config.maxUdpPayload}
648 maxUdpPayload: Number(
658 value={config.queueDepth}
662 queueDepth: Number(event.target.value),
669 justifyContent="space-between"
672 <Typography variant="body2">
676 checked={config.traceEnabled}
680 traceEnabled: event.target.checked,
689 value={config.packetTraceRingSize}
693 packetTraceRingSize: Number(
699 {configErrors.length > 0 && (
700 <Alert severity="warning">
701 {configErrors.join(' ')}
704 <Stack direction="row" spacing={1}>
707 startIcon={<PlayCircleOutlineIcon />}
709 isRunning || configErrors.length > 0
711 onClick={handleStart}
718 startIcon={<StopCircleIcon />}
719 disabled={runState !== 'running'}
729 <Grid size={{ xs: 12, lg: 8 }}>
731 <Paper variant="outlined" sx={{ p: 2 }}>
733 direction={{ xs: 'column', md: 'row' }}
735 justifyContent="space-between"
738 <Typography variant="overline">
741 <Typography variant="h6">
742 {config.host}:{config.port}
746 <Typography variant="overline">
749 <Typography variant="h6">
750 {finalVita49Metadata?.class_id ??
751 '0xFA52530001000101'}
755 <Typography variant="overline">
758 <Typography variant="h6">
760 streamStats?.epoch_unix_nanoseconds ??
761 finalVita49Metadata?.epoch_unix_nanoseconds
768 <Grid container spacing={1}>
770 ['Packets', aggregate.packets],
771 ['Samples', aggregate.samples],
772 ['Drops', aggregate.drops],
773 ['Late', aggregate.late],
774 ['Over-range', aggregate.overRange],
775 ['Context', aggregate.context],
776 ].map(([label, value]) => (
777 <Grid size={{ xs: 6, md: 2 }} key={label}>
778 <Paper variant="outlined" sx={{ p: 1.5 }}>
779 <Typography variant="caption">
782 <Typography variant="h6">
783 {formatMetric(value as number)}
793 <Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
796 justifyContent="space-between"
800 <Typography variant="h6">Streams</Typography>
803 startIcon={<SaveAltIcon />}
804 disabled={!finalVita49Metadata}
805 onClick={exportMetadataJson}
810 {metadataExportPath && (
813 color="text.secondary"
814 sx={{ mb: 1, overflowWrap: 'anywhere' }}
823 <TableCell>Receiver</TableCell>
824 <TableCell>Stream ID</TableCell>
825 <TableCell align="right">Rate</TableCell>
826 <TableCell align="right">RF</TableCell>
827 <TableCell align="right">Packets</TableCell>
828 <TableCell align="right">Samples</TableCell>
829 <TableCell align="right">Drops</TableCell>
830 <TableCell align="right">Late</TableCell>
831 <TableCell align="right">Context</TableCell>
832 <TableCell>Simulation span</TableCell>
833 <TableCell>UTC span</TableCell>
837 {streamRows.map((row) => (
838 <TableRow key={row.key}>
840 <Stack spacing={0.25}>
841 <Typography variant="body2">
846 color="text.secondary"
849 ? `${row.platformName} / ${row.mode}`
855 {formatStreamId(row.streamId)}
857 <TableCell align="right">
858 {formatMetric(row.sampleRate)}
860 <TableCell align="right">
861 {formatMetric(row.referenceFrequency)}
863 <TableCell align="right">
864 {formatMetric(row.packetsEmitted)}
866 <TableCell align="right">
867 {formatMetric(row.samplesEmitted)}
869 <TableCell align="right">
870 {formatMetric(row.packetsDropped)}
872 <TableCell align="right">
873 {formatMetric(row.latePacketCount)}
875 <TableCell align="right">
876 {formatMetric(row.contextPackets)}
879 {formatSimulationSpan(
885 {formatTimestampSpan(
892 {streamRows.length === 0 && (
894 <TableCell colSpan={11}>
895 <Typography color="text.secondary">
906 <Paper variant="outlined" sx={{ p: 2 }}>
908 direction={{ xs: 'column', md: 'row' }}
910 alignItems={{ xs: 'stretch', md: 'center' }}
913 <Typography variant="h6" sx={{ flexGrow: 1 }}>
916 <FilterListIcon color="action" />
917 <FormControl size="small" sx={{ minWidth: 160 }}>
918 <InputLabel id="vita49-stream-filter-label">
922 labelId="vita49-stream-filter-label"
926 setStreamFilter(event.target.value)
929 <MenuItem value="all">All</MenuItem>
930 {streamIdOptions.map((streamId) => (
932 value={String(streamId)}
935 {formatStreamId(streamId)}
940 <FormControl size="small" sx={{ minWidth: 140 }}>
941 <InputLabel id="vita49-kind-filter-label">
945 labelId="vita49-kind-filter-label"
947 value={packetKindFilter}
949 setPacketKindFilter(event.target.value)
952 <MenuItem value="all">All</MenuItem>
953 <MenuItem value="data">Data</MenuItem>
954 <MenuItem value="context">Context</MenuItem>
958 ['Dropped', droppedOnly, setDroppedOnly],
959 ['Over-range', overRangeOnly, setOverRangeOnly],
960 ['Sample-loss', sampleLossOnly, setSampleLossOnly],
961 ].map(([label, checked, setter]) => (
966 key={label as string}
970 checked={checked as boolean}
972 (setter as (value: boolean) => void)(
977 <Typography variant="body2">
983 {omittedPacketTraceEvents > 0 && (
984 <Alert severity="info" sx={{ mb: 2 }}>
985 Showing last {formatMetric(packetTrace.length)} trace
986 events; {formatMetric(omittedPacketTraceEvents)} older
987 trace events discarded from trace history. Stream
988 packets and samples unaffected.
992 ref={packetTraceContainerRef}
994 setPacketTraceScrollTop(event.currentTarget.scrollTop)
996 sx={{ maxHeight: PACKET_TRACE_TABLE_HEIGHT }}
998 <Table size="small" stickyHeader>
1001 <TableCell align="right">Seq</TableCell>
1002 <TableCell>Event</TableCell>
1003 <TableCell>Stream</TableCell>
1004 <TableCell align="right">Bytes</TableCell>
1005 <TableCell align="right">Samples</TableCell>
1006 <TableCell align="right">t</TableCell>
1007 <TableCell align="right">UTC</TableCell>
1008 <TableCell>Flags</TableCell>
1012 {packetTraceWindow.topSpacerHeight > 0 && (
1015 height: `${packetTraceWindow.topSpacerHeight}px`,
1020 sx={{ p: 0, border: 0 }}
1024 {packetTraceWindow.packets.map((packet) => (
1025 <TableRow key={packet.sequence}>
1026 <TableCell align="right">
1029 <TableCell>{packet.event}</TableCell>
1031 {formatStreamId(packet.stream_id)}
1033 <TableCell align="right">
1034 {formatMetric(packet.byte_count)}
1036 <TableCell align="right">
1037 {formatMetric(packet.sample_count)}
1039 <TableCell align="right">
1041 packet.first_sample_time
1044 <TableCell align="right">
1045 {formatVita49Timestamp(
1050 <Stack direction="row" spacing={0.5}>
1051 {packet.dropped && (
1058 {packet.over_range && (
1065 {packet.sample_loss && (
1076 {packetTraceWindow.bottomSpacerHeight > 0 && (
1079 height: `${packetTraceWindow.bottomSpacerHeight}px`,
1084 sx={{ p: 0, border: 0 }}
1088 {filteredPackets.length === 0 && (
1090 <TableCell colSpan={8}>
1091 <Typography color="text.secondary">