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';
43} from '@/components/Vita49LatePacketInfo';
44import { useScenarioStore } from '@/stores/scenarioStore';
45import { getBlockingFmcwValidationMessage } from '@/stores/scenarioStore/fmcwValidation';
47 normalizeSimulationOutputMetadata,
48 type RawSimulationOutputMetadata,
49} from '@/stores/simulationProgressStore';
51 deriveExpectedVita49Streams,
52 mergeVita49StreamRows,
53 toVita49BackendConfig,
54 useVita49StreamingStore,
55 type Vita49PacketTraceEvent,
56 type Vita49StreamStatsEvent,
57 type Vita49TelemetryPoll,
60} from '@/stores/vita49StreamingStore';
62const TELEMETRY_POLL_INTERVAL_MS = 100;
63const TELEMETRY_POLL_ERROR_INTERVAL_MS = 250;
64const TELEMETRY_POLL_PACKET_LIMIT = 1000;
65const PACKET_TRACE_TABLE_HEIGHT = 420;
66const PACKET_TRACE_ROW_HEIGHT = 36;
67const PACKET_TRACE_OVERSCAN = 8;
69const formatMetric = (value: number | null | undefined) =>
70 value === null || value === undefined
72 : value.toLocaleString(undefined, { maximumSignificantDigits: 6 });
74const formatExact = (value: string | number | null | undefined) =>
75 value === null || value === undefined ? '-' : String(value);
77const formatSeconds = (value: number | null | undefined) =>
78 value === null || value === undefined
80 : `${value.toLocaleString(undefined, { maximumSignificantDigits: 9 })} s`;
82const formatVita49Timestamp = (
83 timestamp: Vita49Timestamp | null | undefined
89 const second = Number(timestamp.integer_seconds);
90 if (!Number.isFinite(second)) {
94 const base = new Date(second * 1000).toISOString().replace('.000Z', '');
95 const fractionalPicoseconds = Math.trunc(timestamp.fractional_picoseconds)
99 return fractionalPicoseconds
100 ? `${base}.${fractionalPicoseconds}Z`
104const formatSimulationSpan = (
105 start: number | null | undefined,
106 end: number | null | undefined
108 const first = formatSeconds(start);
109 const last = formatSeconds(end);
110 return first === '-' && last === '-' ? '-' : `${first} - ${last}`;
113const formatTimestampSpan = (
114 start: Vita49Timestamp | null | undefined,
115 end: Vita49Timestamp | null | undefined
117 const first = formatVita49Timestamp(start);
118 const last = formatVita49Timestamp(end);
119 return first === '-' && last === '-' ? '-' : `${first} - ${last}`;
122const formatStreamId = (streamId: number | null | undefined) =>
123 streamId === null || streamId === undefined
125 : `0x${streamId.toString(16).toUpperCase().padStart(8, '0')}`;
127export const Vita49StreamingView = React.memo(function Vita49StreamingView() {
128 const config = useVita49StreamingStore((state) => state.config);
129 const runState = useVita49StreamingStore((state) => state.runState);
130 const expectedStreams = useVita49StreamingStore(
131 (state) => state.expectedStreams
133 const streamStats = useVita49StreamingStore((state) => state.streamStats);
134 const packetTrace = useVita49StreamingStore((state) => state.packetTrace);
135 const omittedPacketTraceEvents = useVita49StreamingStore(
136 (state) => state.omittedPacketTraceEvents
138 const finalVita49Metadata = useVita49StreamingStore(
139 (state) => state.finalVita49Metadata
141 const error = useVita49StreamingStore((state) => state.error);
142 const setConfig = useVita49StreamingStore((state) => state.setConfig);
143 const startRun = useVita49StreamingStore((state) => state.startRun);
144 const markStopping = useVita49StreamingStore((state) => state.markStopping);
145 const markDraining = useVita49StreamingStore((state) => state.markDraining);
146 const setStreamStats = useVita49StreamingStore(
147 (state) => state.setStreamStats
149 const appendPacketBatch = useVita49StreamingStore(
150 (state) => state.appendPacketBatch
152 const completeRun = useVita49StreamingStore((state) => state.completeRun);
153 const cancelRun = useVita49StreamingStore((state) => state.cancelRun);
154 const failRun = useVita49StreamingStore((state) => state.failRun);
155 const showError = useScenarioStore((state) => state.showError);
156 const showWarning = useScenarioStore((state) => state.showWarning);
157 const showSuccess = useScenarioStore((state) => state.showSuccess);
158 const scenarioFilePath = useScenarioStore(
159 (state) => state.scenarioFilePath
161 const outputDirectory = useScenarioStore((state) => state.outputDirectory);
163 const [metadataExportPath, setMetadataExportPath] = useState<string | null>(
166 const [streamFilter, setStreamFilter] = useState('all');
167 const [packetKindFilter, setPacketKindFilter] = useState('all');
168 const [droppedOnly, setDroppedOnly] = useState(false);
169 const [overRangeOnly, setOverRangeOnly] = useState(false);
170 const [sampleLossOnly, setSampleLossOnly] = useState(false);
171 const [packetTraceScrollTop, setPacketTraceScrollTop] = useState(0);
172 const packetTraceContainerRef = useRef<HTMLDivElement | null>(null);
173 const pendingTelemetryRef = useRef<{
174 stats: Vita49StreamStatsEvent | null;
175 packets: Vita49PacketTraceEvent[];
176 omittedPacketTraceEvents: number;
180 omittedPacketTraceEvents: 0,
182 const telemetryFlushFrameRef = useRef<number | null>(null);
184 runState === 'running' ||
185 runState === 'stopping' ||
186 runState === 'draining';
187 const configErrors = validateVita49Config(config);
189 const appendTelemetry = useCallback(
190 (telemetry: Vita49TelemetryPoll) => {
191 if (telemetry.stats) {
192 setStreamStats(telemetry.stats);
195 telemetry.packets.length > 0 ||
196 telemetry.omitted_packet_trace_events > 0
200 telemetry.omitted_packet_trace_events
204 [appendPacketBatch, setStreamStats]
207 const flushPendingTelemetry = useCallback(() => {
208 if (telemetryFlushFrameRef.current !== null) {
209 window.cancelAnimationFrame(telemetryFlushFrameRef.current);
210 telemetryFlushFrameRef.current = null;
213 const pending = pendingTelemetryRef.current;
214 pendingTelemetryRef.current = {
217 omittedPacketTraceEvents: 0,
221 setStreamStats(pending.stats);
224 pending.packets.length > 0 ||
225 pending.omittedPacketTraceEvents > 0
229 pending.omittedPacketTraceEvents
232 }, [appendPacketBatch, setStreamStats]);
234 const drainAvailableTelemetry = useCallback(async () => {
235 flushPendingTelemetry();
238 const telemetry = await invoke<Vita49TelemetryPoll>(
239 'poll_vita49_telemetry',
240 { maxPackets: TELEMETRY_POLL_PACKET_LIMIT }
242 appendTelemetry(telemetry);
243 hasMore = telemetry.has_more;
245 flushPendingTelemetry();
246 }, [appendTelemetry, flushPendingTelemetry]);
250 const unlisteners = Promise.all([
251 listen<string>('vita49-output-metadata', (event) => {
253 const metadata = normalizeSimulationOutputMetadata(
254 JSON.parse(event.payload) as RawSimulationOutputMetadata
256 void drainAvailableTelemetry().finally(() => {
257 if (active) completeRun(metadata);
260 listen<string>('vita49-stream-complete', (event) => {
262 const metadata = normalizeSimulationOutputMetadata(
263 JSON.parse(event.payload) as RawSimulationOutputMetadata
265 void drainAvailableTelemetry().finally(() => {
266 if (active) completeRun(metadata);
269 listen<string>('vita49-stream-draining', () => {
273 listen<string>('vita49-stream-cancelled', (event) => {
275 const metadata = normalizeSimulationOutputMetadata(
276 JSON.parse(event.payload) as RawSimulationOutputMetadata
278 void drainAvailableTelemetry().finally(() => {
279 if (active) cancelRun(metadata);
282 listen<string>('vita49-stream-error', (event) => {
284 failRun(event.payload);
285 showError(`VITA49 streaming failed: ${event.payload}`);
291 unlisteners.then((listeners) =>
292 listeners.forEach((unlisten) => unlisten())
298 drainAvailableTelemetry,
309 let cancelled = false;
310 let pollTimer: ReturnType<typeof setTimeout> | null = null;
312 const scheduleFlush = () => {
313 if (telemetryFlushFrameRef.current !== null) {
316 telemetryFlushFrameRef.current = window.requestAnimationFrame(
317 flushPendingTelemetry
321 const queueTelemetry = (telemetry: Vita49TelemetryPoll) => {
322 const pending = pendingTelemetryRef.current;
323 pending.stats = telemetry.stats ?? pending.stats;
324 pending.packets.push(...telemetry.packets);
325 pending.omittedPacketTraceEvents +=
326 telemetry.omitted_packet_trace_events;
330 const pollTelemetry = async () => {
338 const telemetry = await invoke<Vita49TelemetryPoll>(
339 'poll_vita49_telemetry',
340 { maxPackets: TELEMETRY_POLL_PACKET_LIMIT }
342 queueTelemetry(telemetry);
343 hasMore = telemetry.has_more;
344 } while (!cancelled && hasMore);
347 pollTimer = setTimeout(
349 TELEMETRY_POLL_INTERVAL_MS
353 console.error('Failed to poll VITA49 telemetry:', err);
355 pollTimer = setTimeout(
357 TELEMETRY_POLL_ERROR_INTERVAL_MS
363 void pollTelemetry();
367 if (pollTimer !== null) {
368 clearTimeout(pollTimer);
370 flushPendingTelemetry();
372 }, [flushPendingTelemetry, isRunning]);
374 const streamRows = useMemo(
375 () => mergeVita49StreamRows(expectedStreams, streamStats),
376 [expectedStreams, streamStats]
379 const aggregate = useMemo(
383 packets: acc.packets + row.packetsEmitted,
384 samples: acc.samples + row.samplesEmitted,
385 drops: acc.drops + row.packetsDropped,
386 lateData: acc.lateData + row.lateDataPacketCount,
387 lateContext: acc.lateContext + row.lateContextPacketCount,
388 overRange: acc.overRange + row.overRangeCount,
389 context: acc.context + row.contextPackets,
404 const streamIdOptions = useMemo(() => {
405 const ids = new Set<number>();
406 for (const row of streamRows) {
407 if (row.streamId !== null) ids.add(row.streamId);
409 for (const packet of packetTrace) {
410 if (packet.stream_id) ids.add(packet.stream_id);
412 return Array.from(ids).sort((a, b) => a - b);
413 }, [packetTrace, streamRows]);
415 const filteredPackets = useMemo(
417 packetTrace.filter((packet) => {
419 streamFilter !== 'all' &&
420 packet.stream_id !== Number(streamFilter)
424 if (packetKindFilter === 'data' && !packet.data_packet) {
427 if (packetKindFilter === 'context' && !packet.context_packet) {
430 if (droppedOnly && !packet.dropped) return false;
431 if (overRangeOnly && !packet.over_range) return false;
432 if (sampleLossOnly && !packet.sample_loss) return false;
446 setPacketTraceScrollTop(0);
447 if (packetTraceContainerRef.current) {
448 packetTraceContainerRef.current.scrollTop = 0;
458 const packetTraceWindow = useMemo(() => {
459 const rawStart = Math.max(
461 Math.floor(packetTraceScrollTop / PACKET_TRACE_ROW_HEIGHT) -
462 PACKET_TRACE_OVERSCAN
464 const start = Math.min(filteredPackets.length, rawStart);
465 const end = Math.min(
466 filteredPackets.length,
468 (packetTraceScrollTop + PACKET_TRACE_TABLE_HEIGHT) /
469 PACKET_TRACE_ROW_HEIGHT
470 ) + PACKET_TRACE_OVERSCAN
475 packets: filteredPackets.slice(start, end),
476 topSpacerHeight: start * PACKET_TRACE_ROW_HEIGHT,
478 Math.max(0, filteredPackets.length - end) *
479 PACKET_TRACE_ROW_HEIGHT,
481 }, [filteredPackets, packetTraceScrollTop]);
483 const getEffectiveOutputDir = async () => {
484 if (outputDirectory) return outputDirectory;
485 if (scenarioFilePath) {
486 return dirname(scenarioFilePath);
491 const handleStart = async () => {
492 const scenarioState = useScenarioStore.getState();
493 const validationMessage =
494 getBlockingFmcwValidationMessage(scenarioState);
495 if (validationMessage) {
496 showError(`FMCW validation failed: ${validationMessage}`);
500 const errors = validateVita49Config(config);
501 if (errors.length > 0) {
502 showError(errors.join(' '));
506 const expected = deriveExpectedVita49Streams(scenarioState);
507 if (expected.length === 0) {
508 showWarning('No receiver streams are configured.');
511 setMetadataExportPath(null);
514 await invoke('set_output_directory', {
515 dir: await getEffectiveOutputDir(),
517 await scenarioState.syncBackend();
518 await invoke('start_vita49_stream', {
519 config: toVita49BackendConfig(config),
522 const message = err instanceof Error ? err.message : String(err);
524 showError(`Failed to start VITA49 streaming: ${message}`);
528 const handleStop = async () => {
531 await invoke('stop_simulation');
533 const message = err instanceof Error ? err.message : String(err);
534 showError(`Failed to stop simulation: ${message}`);
538 const exportMetadataJson = async () => {
540 const outputPath = await invoke<string>(
541 'export_output_metadata_json'
543 setMetadataExportPath(outputPath);
544 showSuccess(`Metadata JSON saved to ${outputPath}`);
546 const message = err instanceof Error ? err.message : String(err);
547 showError(`Failed to export metadata JSON: ${message}`);
552 <Box sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
559 <Typography variant="h4">VITA49 Streams</Typography>
562 color={isRunning ? 'primary' : 'default'}
567 <Alert severity="error" sx={{ mb: 2 }}>
572 <Grid container spacing={2} sx={{ mb: 2 }}>
573 <Grid size={{ xs: 12, lg: 4 }}>
574 <Paper variant="outlined" sx={{ p: 2, height: '100%' }}>
575 <Typography variant="h6" sx={{ mb: 2 }}>
585 setConfig({ host: event.target.value })
596 port: Number(event.target.value),
604 value={config.fullscale}
608 fullscale: Number(event.target.value),
612 <FormControl size="small">
613 <InputLabel id="vita49-epoch-mode-label">
617 labelId="vita49-epoch-mode-label"
619 value={config.epochMode}
623 epochMode: event.target
624 .value as typeof config.epochMode,
628 <MenuItem value="auto">Auto</MenuItem>
629 <MenuItem value="fixed">Fixed</MenuItem>
632 {config.epochMode === 'fixed' && (
636 value={config.epochUnixNanoseconds}
640 epochUnixNanoseconds:
647 label="Max UDP payload"
650 value={config.maxUdpPayload}
654 maxUdpPayload: Number(
664 value={config.queueDepth}
668 queueDepth: Number(event.target.value),
675 justifyContent="space-between"
678 <Typography variant="body2">
682 checked={config.traceEnabled}
686 traceEnabled: event.target.checked,
695 value={config.packetTraceRingSize}
699 packetTraceRingSize: Number(
705 {configErrors.length > 0 && (
706 <Alert severity="warning">
707 {configErrors.join(' ')}
710 <Stack direction="row" spacing={1}>
713 startIcon={<PlayCircleOutlineIcon />}
715 isRunning || configErrors.length > 0
717 onClick={handleStart}
724 startIcon={<StopCircleIcon />}
725 disabled={runState !== 'running'}
735 <Grid size={{ xs: 12, lg: 8 }}>
737 <Paper variant="outlined" sx={{ p: 2 }}>
739 direction={{ xs: 'column', md: 'row' }}
741 justifyContent="space-between"
744 <Typography variant="overline">
747 <Typography variant="h6">
748 {config.host}:{config.port}
752 <Typography variant="overline">
755 <Typography variant="h6">
756 {finalVita49Metadata?.class_id ??
757 '0xFA52530001000101'}
761 <Typography variant="overline">
764 <Typography variant="h6">
766 streamStats?.epoch_unix_nanoseconds ??
767 finalVita49Metadata?.epoch_unix_nanoseconds
774 <Grid container spacing={1}>
776 { label: 'Packets', value: aggregate.packets },
777 { label: 'Samples', value: aggregate.samples },
778 { label: 'Drops', value: aggregate.drops },
781 value: totalLatePacketCount(
783 aggregate.lateContext
789 value: aggregate.overRange,
791 { label: 'Context', value: aggregate.context },
792 ].map(({ label, value, lateInfo }) => (
793 <Grid size={{ xs: 6, md: 2 }} key={label}>
794 <Paper variant="outlined" sx={{ p: 1.5 }}>
799 <Typography variant="caption">
803 <Vita49LatePacketInfo
808 aggregate.lateContext
813 <Typography variant="h6">
814 {formatMetric(value)}
824 <Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
827 justifyContent="space-between"
831 <Typography variant="h6">Streams</Typography>
834 startIcon={<SaveAltIcon />}
835 disabled={!finalVita49Metadata}
836 onClick={exportMetadataJson}
841 {metadataExportPath && (
844 color="text.secondary"
845 sx={{ mb: 1, overflowWrap: 'anywhere' }}
854 <TableCell>Receiver</TableCell>
855 <TableCell>Stream ID</TableCell>
856 <TableCell align="right">Rate</TableCell>
857 <TableCell align="right">RF</TableCell>
858 <TableCell align="right">Packets</TableCell>
859 <TableCell align="right">Samples</TableCell>
860 <TableCell align="right">Drops</TableCell>
861 <TableCell align="right">
865 justifyContent="flex-end"
868 <Vita49LatePacketInfo
869 dataPacketCount={aggregate.lateData}
871 aggregate.lateContext
876 <TableCell align="right">Context</TableCell>
877 <TableCell>Simulation span</TableCell>
878 <TableCell>UTC span</TableCell>
882 {streamRows.map((row) => (
883 <TableRow key={row.key}>
885 <Stack spacing={0.25}>
886 <Typography variant="body2">
891 color="text.secondary"
894 ? `${row.platformName} / ${row.mode}`
900 {formatStreamId(row.streamId)}
902 <TableCell align="right">
903 {formatMetric(row.sampleRate)}
905 <TableCell align="right">
906 {formatMetric(row.referenceFrequency)}
908 <TableCell align="right">
909 {formatMetric(row.packetsEmitted)}
911 <TableCell align="right">
912 {formatMetric(row.samplesEmitted)}
914 <TableCell align="right">
915 {formatMetric(row.packetsDropped)}
917 <TableCell align="right">
921 justifyContent="flex-end"
924 totalLatePacketCount(
925 row.lateDataPacketCount,
926 row.lateContextPacketCount
929 <Vita49LatePacketInfo
931 row.lateDataPacketCount
934 row.lateContextPacketCount
939 <TableCell align="right">
940 {formatMetric(row.contextPackets)}
943 {formatSimulationSpan(
949 {formatTimestampSpan(
956 {streamRows.length === 0 && (
958 <TableCell colSpan={11}>
959 <Typography color="text.secondary">
970 <Paper variant="outlined" sx={{ p: 2 }}>
972 direction={{ xs: 'column', md: 'row' }}
974 alignItems={{ xs: 'stretch', md: 'center' }}
977 <Typography variant="h6" sx={{ flexGrow: 1 }}>
980 <FilterListIcon color="action" />
981 <FormControl size="small" sx={{ minWidth: 160 }}>
982 <InputLabel id="vita49-stream-filter-label">
986 labelId="vita49-stream-filter-label"
990 setStreamFilter(event.target.value)
993 <MenuItem value="all">All</MenuItem>
994 {streamIdOptions.map((streamId) => (
996 value={String(streamId)}
999 {formatStreamId(streamId)}
1004 <FormControl size="small" sx={{ minWidth: 140 }}>
1005 <InputLabel id="vita49-kind-filter-label">
1009 labelId="vita49-kind-filter-label"
1011 value={packetKindFilter}
1012 onChange={(event) =>
1013 setPacketKindFilter(event.target.value)
1016 <MenuItem value="all">All</MenuItem>
1017 <MenuItem value="data">Data</MenuItem>
1018 <MenuItem value="context">Context</MenuItem>
1022 ['Dropped', droppedOnly, setDroppedOnly],
1023 ['Over-range', overRangeOnly, setOverRangeOnly],
1024 ['Sample-loss', sampleLossOnly, setSampleLossOnly],
1025 ].map(([label, checked, setter]) => (
1030 key={label as string}
1034 checked={checked as boolean}
1035 onChange={(event) =>
1036 (setter as (value: boolean) => void)(
1037 event.target.checked
1041 <Typography variant="body2">
1047 {omittedPacketTraceEvents > 0 && (
1048 <Alert severity="info" sx={{ mb: 2 }}>
1049 Showing last {formatMetric(packetTrace.length)} trace
1050 events; {formatMetric(omittedPacketTraceEvents)} older
1051 trace events discarded from trace history. Stream
1052 packets and samples unaffected.
1056 ref={packetTraceContainerRef}
1057 onScroll={(event) =>
1058 setPacketTraceScrollTop(event.currentTarget.scrollTop)
1060 sx={{ maxHeight: PACKET_TRACE_TABLE_HEIGHT }}
1062 <Table size="small" stickyHeader>
1065 <TableCell align="right">Seq</TableCell>
1066 <TableCell>Event</TableCell>
1067 <TableCell>Stream</TableCell>
1068 <TableCell align="right">Bytes</TableCell>
1069 <TableCell align="right">Samples</TableCell>
1070 <TableCell align="right">t</TableCell>
1071 <TableCell align="right">UTC</TableCell>
1072 <TableCell>Flags</TableCell>
1076 {packetTraceWindow.topSpacerHeight > 0 && (
1079 height: `${packetTraceWindow.topSpacerHeight}px`,
1084 sx={{ p: 0, border: 0 }}
1088 {packetTraceWindow.packets.map((packet) => (
1089 <TableRow key={packet.sequence}>
1090 <TableCell align="right">
1093 <TableCell>{packet.event}</TableCell>
1095 {formatStreamId(packet.stream_id)}
1097 <TableCell align="right">
1098 {formatMetric(packet.byte_count)}
1100 <TableCell align="right">
1101 {formatMetric(packet.sample_count)}
1103 <TableCell align="right">
1105 packet.first_sample_time
1108 <TableCell align="right">
1109 {formatVita49Timestamp(
1114 <Stack direction="row" spacing={0.5}>
1115 {packet.dropped && (
1122 {packet.over_range && (
1129 {packet.sample_loss && (
1140 {packetTraceWindow.bottomSpacerHeight > 0 && (
1143 height: `${packetTraceWindow.bottomSpacerHeight}px`,
1148 sx={{ p: 0, border: 0 }}
1152 {filteredPackets.length === 0 && (
1154 <TableCell colSpan={8}>
1155 <Typography color="text.secondary">