= ({
onSelect,
selectedHash,
}) => {
+ const formatSrcDst = (srcHash?: string, dstHash?: string, reverse: boolean = false): JSX.Element => {
+ const src = srcHash ? srcHash.slice(0, 8) : '??';
+ const dst = dstHash ? dstHash.slice(0, 8) : '??';
+ return (
+
+ {src} {reverse ? '←' : '→'} {dst}
+
+ );
+ }
+
+ const PayloadTextSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ // If the packet contains a decrypted group message, prefer that
+ if (packet.decrypted && packet.payloadType === PayloadType.GROUP_TEXT) {
+ const dg = packet.decrypted as DecryptedGroupMessage;
+ let nick: string | null = null;
+ let message: string | null = null;
+
+ if (typeof dg.message === 'string') {
+ const parts = dg.message.split(':');
+ if (parts.length > 1) {
+ nick = parts.shift()!.trim();
+ message = parts.join(':').trim();
+ } else {
+ message = dg.message;
+ }
+ }
+
+ if (nick && message) {
+ return (
+
+ {dg.group && {dg.group} }
+ {nick}
+ {': '}
+ {message}
+
+ );
+ }
+
+ if (message) {
+ return {message}
;
+ }
+ }
+
+ // Fallback: show channel hash when payload decoded but not decrypted, or raw hex
+ const payload = packet.decodedPayload as GroupTextPayload;
+ return (
+
+ Group message:
+ #{payload.channelHash}
+
+ );
+ };
+
+ const TextPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as TextPayload;
+ const dec = packet.decrypted as DecryptedTextMessage | undefined;
+ if (dec && typeof dec.message === 'string') return {dec.message}
;
+ return (
+
+ Text message:
+ {formatSrcDst(payload.srcHash, payload.dstHash)}
+
+ );
+ };
+
+ const RequestPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as RequestPayload;
+ const dec = packet.decrypted as DecryptedRequest | undefined;
+ if (dec) {
+ return (
+
+
{`Request type: ${dec.requestType}`}
+
{`Timestamp: ${dec.timestamp.toLocaleString()}`}
+
+ );
+ }
+ return (
+
+ Request:
+ {formatSrcDst(payload.srcHash, payload.dstHash)}
+
+ );
+ };
+
+ const ResponsePayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as ResponsePayload;
+ const dec = packet.decrypted as DecryptedResponse | undefined;
+ if (dec) {
+ return {`Response tag: ${dec.tag}`}
;
+ }
+ return (
+
+ Response:
+ {formatSrcDst(payload.srcHash, payload.dstHash, true)}
+
+ );
+ };
+
+ const AdvertPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as AdvertPayload;
+ return (
+
+ {payload.appdata?.name ? payload.appdata.name : bytesToHex(payload.publicKey)}
+ {(payload.appdata?.latitude !== undefined && payload.appdata?.longitude !== undefined) ? (
+
+
+ {`${payload.appdata.latitude.toFixed(6)}, ${payload.appdata.longitude.toFixed(6)}`}
+
+ ) : (
+
+
+ No location
+
+ )}
+
+ );
+ };
+
+ const PathPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as PathPayload;
+ const dec = packet.decrypted as DecryptedPath | undefined;
+ if (dec) {
+ return (
+
+
{`Path (${dec.path.length} hops)`}
+
+ );
+ }
+ return Path
;
+ };
+
+ const TracePayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as TracePayload;
+ const nodesBuf = payload.nodes ?? new Uint8Array();
+ const nodes: { id: string; snr: number }[] = [];
+ for (let offset = 0; offset + 1 < nodesBuf.length; offset += 2) {
+ const nodeId = nodesBuf[offset].toString(16).padStart(2, '0');
+ const snr = new Int8Array(nodesBuf.buffer, nodesBuf.byteOffset + offset + 1, 1)[0];
+ nodes.push({ id: nodeId, snr: snr / 4.0}); // SNR is encoded as 1/4 dB steps
+ }
+
+ return (
+
+ {nodes.map((n, i) => (
+
+
+ {`${n.id !== '00' ? n.id : 'origin'} (${n.snr}dB)`}
+
+ {i < nodes.length - 1 && }
+
+ ))}
+
+ );
+ };
+
+ const MultipartPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as MultipartPayload;
+ return {`Multipart (${payload.data?.length ?? 0} bytes)`}
;
+ };
+
+ const ControlPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as ControlPayload;
+ return {`Control flags: 0x${payload.flags.toString(16)}`}
;
+ };
+
+ const AckPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as AckPayload;
+ return (
+
+ Acknowledgement: {bytesToHex(payload.checksum)}
+
+ );
+ };
+
+ const GroupDataPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as GroupDataPayload;
+ return Encrypted group datagram {`channel: #${payload.channelHash}`}
;
+ };
+
+ const AnonReqPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as AnonReqPayload;
+ return (
+
+ Anonymous request to:
+ {payload.dstHash}
+
+ );
+ };
+
+ const RawCustomPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
+ const payload = packet.decodedPayload as RawCustomPayload;
+ return {`Raw custom payload (${payload.data?.length ?? 0} bytes)`}
;
+ };
+
+ const renderPayloadSummary = (packet: Packet): React.ReactNode => {
+ if (typeof packet.decodedPayload !== 'undefined') {
+ switch (packet.payloadType) {
+ case PayloadType.GROUP_TEXT:
+ return ;
+ case PayloadType.TEXT:
+ return ;
+ case PayloadType.REQUEST:
+ return ;
+ case PayloadType.RESPONSE:
+ return ;
+ case PayloadType.ADVERT:
+ return ;
+ case PayloadType.PATH:
+ return ;
+ case PayloadType.TRACE:
+ return ;
+ case PayloadType.MULTIPART:
+ return ;
+ case PayloadType.CONTROL:
+ return ;
+ case PayloadType.ACK:
+ return ;
+ case PayloadType.GROUP_DATA:
+ return ;
+ case PayloadType.ANON_REQ:
+ return ;
+ case PayloadType.RAW_CUSTOM:
+ return ;
+ }
+ }
+ const hex = bytesToHex(packet.payload ?? new Uint8Array());
+ return {hex.length > 48 ? `${hex.slice(0, 48)}…` : hex} ;
+ };
return (
<>
{groupedPackets.map((group) => {
@@ -98,14 +347,14 @@ const MeshCorePacketRows: React.FC = ({
if (pathA !== pathB) {
return pathA - pathB;
}
- return b.timestamp.getTime() - a.timestamp.getTime();
+ return b.receivedAt.getTime() - a.receivedAt.getTime();
});
return (
onSelect(packet)}
>
@@ -123,42 +372,49 @@ const MeshCorePacketRows: React.FC = ({
)}
-
- {packet.timestamp.toLocaleTimeString()}
+
+ {packet.receivedAt.toLocaleTimeString()}
{hasDuplicates && (
{' '}×{group.packets.length}
)}
- {packet.snr !== undefined ? packet.snr.toFixed(1) : '-'} dB
+
+ {packet.snr !== undefined && packet.snr !== null ? packet.snr.toFixed(0) + 'dB' : '-'}
+
- onSelect(packet)}>
- {packet.hash}
-
+
+
+
+
+ {packet.hash()}
+
- {packet.payloadSummary}
+ {renderPayloadSummary(packet)}
{isExpanded && expandedPackets.map((duplicatePacket, index) => (
onSelect(duplicatePacket)}
>
- {duplicatePacket.timestamp.toLocaleTimeString()}
+ {duplicatePacket.receivedAt.toLocaleTimeString()}
onSelect(duplicatePacket)}>
- {duplicatePacket.hash}
+ {duplicatePacket.hash()}
- {duplicatePacket.snr !== undefined ? duplicatePacket.snr.toFixed(1) : '-'} dB
+
+
+
{getPathInfo(duplicatePacket).prefixes}
diff --git a/ui/src/pages/meshcore/MeshCorePacketTable.tsx b/ui/src/pages/meshcore/MeshCorePacketTable.tsx
index 641026c..0486f61 100644
--- a/ui/src/pages/meshcore/MeshCorePacketTable.tsx
+++ b/ui/src/pages/meshcore/MeshCorePacketTable.tsx
@@ -9,7 +9,6 @@ import StreamStatus from '../../components/StreamStatus';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
-import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadUrlByValue,
payloadValueByUrl,
@@ -18,11 +17,12 @@ import {
} from './MeshCoreData';
import MeshCorePacketFilters from './MeshCorePacketFilters';
import MeshCorePacketRows, { type MeshCorePacketGroup } from './MeshCorePacketRows';
+import type { Packet } from '../../protocols/meshcore';
interface MeshCorePacketTableProps {
- packets: MeshCorePacketRecord[];
+ packets: Packet[];
selectedHash: string | null;
- onSelect: (packet: MeshCorePacketRecord) => void;
+ onSelect: (packet: Packet) => void;
onClearSelection: () => void;
streamReady: boolean;
}
@@ -94,30 +94,34 @@ const MeshCorePacketTable: React.FC = ({ packets, sele
}, [packets, radios]);
const groupedPackets = useMemo((): MeshCorePacketGroup[] => {
- const groups = new Map();
+ const groups = new Map();
- packets.forEach((packet) => {
- if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
- if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
- if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
+ for (const packet of packets) {
+ if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) continue;
+ if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) continue;
+ if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) continue;
- const existing = groups.get(packet.hash);
+ const hash = packet.hash();
+ const existing = groups.get(hash);
if (existing) {
existing.push(packet);
} else {
- groups.set(packet.hash, [packet]);
+ groups.set(hash, [packet]);
}
- });
+ }
return Array.from(groups.entries())
.map(([hash, grouped]) => ({
hash,
- packets: grouped.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
+ packets: grouped.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime()),
+ leastRecent: grouped.reduce((oldest, packet) => (
+ packet.receivedAt < oldest.receivedAt ? packet : oldest
+ )),
mostRecent: grouped.reduce((latest, packet) => (
- packet.timestamp > latest.timestamp ? packet : latest
+ packet.receivedAt > latest.receivedAt ? packet : latest
)),
}))
- .sort((a, b) => b.mostRecent.timestamp.getTime() - a.mostRecent.timestamp.getTime());
+ .sort((a, b) => b.leastRecent.receivedAt.getTime() - a.leastRecent.receivedAt.getTime());
}, [packets, filterPayloadTypes, filterRouteTypes, filterRadios]);
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
@@ -165,7 +169,7 @@ const MeshCorePacketTable: React.FC = ({ packets, sele
if (!selectedHash) {
return null;
}
- const index = navigablePackets.findIndex((packet) => packet.hash === selectedHash);
+ const index = navigablePackets.findIndex((packet) => packet.hash() === selectedHash);
return index >= 0 ? index : null;
}, [navigablePackets, selectedHash]);
@@ -212,11 +216,12 @@ const MeshCorePacketTable: React.FC = ({ packets, sele
-
- Time
- SNR
- Hash
- Type
+
+ Time
+ SNR
+ Quality
+ Hash
+ Type
Info
diff --git a/ui/src/pages/meshcore/MeshCorePacketsView.tsx b/ui/src/pages/meshcore/MeshCorePacketsView.tsx
index e0acab1..15d6d18 100644
--- a/ui/src/pages/meshcore/MeshCorePacketsView.tsx
+++ b/ui/src/pages/meshcore/MeshCorePacketsView.tsx
@@ -13,7 +13,7 @@ const MeshCorePacketsView: React.FC = () => {
if (!selectedHash) {
return null;
}
- return packets.find((packet) => packet.hash === selectedHash) ?? null;
+ return packets.find((packet) => packet.hash() === selectedHash) ?? null;
}, [packets, selectedHash]);
return (
@@ -23,7 +23,7 @@ const MeshCorePacketsView: React.FC = () => {
setSelectedHash(packet.hash)}
+ onSelect={(packet) => setSelectedHash(packet.hash())}
onClearSelection={() => setSelectedHash(null)}
streamReady={streamReady}
/>
diff --git a/ui/src/pages/meshcore/MeshCoreStatsView.tsx b/ui/src/pages/meshcore/MeshCoreStatsView.tsx
new file mode 100644
index 0000000..0f46dba
--- /dev/null
+++ b/ui/src/pages/meshcore/MeshCoreStatsView.tsx
@@ -0,0 +1,596 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Alert, ButtonGroup, Card, Form, ListGroup, Spinner } from 'react-bootstrap';
+import { ChartContainer } from '@mui/x-charts/ChartContainer';
+import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip';
+import { ChartsXAxis } from '@mui/x-charts/ChartsXAxis';
+import { ChartsYAxis } from '@mui/x-charts/ChartsYAxis';
+import { BarChart, BarPlot } from '@mui/x-charts/BarChart';
+import { LineChart, LinePlot, MarkPlot } from '@mui/x-charts/LineChart';
+
+import API from '../../services/API';
+import MeshCoreServiceImpl, {
+ type MeshCoreOriginStats,
+ type MeshCorePacketStats,
+} from '../../services/MeshCoreService';
+import { PayloadType } from '../../types/protocol/meshcore.types';
+import VerticalSplit from '../../components/VerticalSplit';
+
+const meshCoreService = new MeshCoreServiceImpl(API);
+
+type MeshCoreStatsView = 'daily' | 'weekly' | 'monthly';
+
+const viewLabelByKey: Record = {
+ daily: 'Daily',
+ weekly: 'Weekly',
+ monthly: 'Monthly',
+};
+
+const descriptionByView: Record = {
+ daily: 'Last 24 hours in 30-minute buckets.',
+ weekly: 'Last 7 days in 4-hour buckets.',
+ monthly: 'Last 30 days in 1-day buckets.',
+};
+
+const originPalette = [
+ '#FF4D4F',
+ '#40A9FF',
+ '#73D13D',
+ '#9254DE',
+ '#13C2C2',
+ '#EB2F96',
+ '#A0D911',
+ '#FA8C16',
+ '#2F54EB',
+ '#36CFC9',
+ '#F759AB',
+ '#FFC53D',
+ '#B37FEB',
+ '#5CDBD3',
+ '#FF7A45',
+];
+
+const payloadTypeNameByCode: Record = Object.entries(PayloadType).reduce(
+ (accumulator, [name, value]) => {
+ accumulator[String(value)] = name.toLowerCase().replace(/_/g, ' ');
+ return accumulator;
+ },
+ {} as Record
+);
+
+const darkenHexColor = (hexColor: string, factor = 0.72): string => {
+ const normalized = hexColor.replace('#', '');
+ const toComponent = (start: number) => Number.parseInt(normalized.slice(start, start + 2), 16);
+
+ const red = Math.max(0, Math.min(255, Math.round(toComponent(0) * factor)));
+ const green = Math.max(0, Math.min(255, Math.round(toComponent(2) * factor)));
+ const blue = Math.max(0, Math.min(255, Math.round(toComponent(4) * factor)));
+
+ const toHex = (value: number) => value.toString(16).padStart(2, '0');
+ return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
+};
+
+const MeshCoreStatsView: React.FC = () => {
+ const [view, setView] = useState('daily');
+ const [originStats, setOriginStats] = useState(null);
+ const [packetStats, setPacketStats] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const load = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [originResponse, packetResponse] = await Promise.all([
+ meshCoreService.fetchOriginStats(view),
+ meshCoreService.fetchPacketStats(view),
+ ]);
+ if (!isMounted) {
+ return;
+ }
+ setOriginStats(originResponse);
+ setPacketStats(packetResponse);
+ } catch (err) {
+ if (!isMounted) {
+ return;
+ }
+ setError(err instanceof Error ? err.message : 'Failed to load origin statistics');
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void load();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [view]);
+
+ const xValues = useMemo(() => {
+ if (!originStats) {
+ return [] as number[];
+ }
+
+ return originStats.timestamps;
+ }, [originStats]);
+
+ const formatTimestampLabel = (timestamp: number) => {
+ const dateOptions: Intl.DateTimeFormatOptions =
+ view === 'daily'
+ ? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }
+ : view === 'weekly'
+ ? { month: 'short', day: 'numeric', hour: '2-digit' }
+ : { month: 'short', day: 'numeric' };
+
+ return new Date(timestamp * 1000).toLocaleString(undefined, dateOptions);
+ };
+
+ const origins = useMemo(() => {
+ if (!originStats) {
+ return [] as string[];
+ }
+ return Object.keys(originStats.signal).sort((a, b) => a.localeCompare(b));
+ }, [originStats]);
+
+ const originColorMap = useMemo(() => {
+ const colorMap: Record = {};
+ origins.forEach((origin, index) => {
+ const snrColor = originPalette[index % originPalette.length];
+ colorMap[origin] = {
+ snr: snrColor,
+ rssi: darkenHexColor(snrColor),
+ };
+ });
+ return colorMap;
+ }, [origins]);
+
+ const series = useMemo(() => {
+ if (!originStats) {
+ return [];
+ }
+
+ return origins
+ .map((origin) => ({
+ label: origin || 'unknown',
+ data: originStats.counts[origin],
+ stack: 'count',
+ color: originColorMap[origin]?.snr,
+ }));
+ }, [originStats, origins, originColorMap]);
+
+ const signalSeries = useMemo(() => {
+ if (!originStats) {
+ return [];
+ }
+
+ const entries = origins.map((origin) => [origin, originStats.signal[origin]] as const);
+
+ const snrSeries = entries.map(([origin, data]) => ({
+ label: `SNR · ${origin || 'unknown'}`,
+ data: data.snr,
+ showMark: false,
+ yAxisId: 'snr',
+ color: originColorMap[origin]?.snr,
+ }));
+
+ const rssiSeries = entries.map(([origin, data]) => ({
+ label: `RSSI · ${origin || 'unknown'}`,
+ data: data.rssi,
+ showMark: false,
+ yAxisId: 'rssi',
+ color: originColorMap[origin]?.rssi,
+ }));
+
+ return [...snrSeries, ...rssiSeries];
+ }, [originStats, origins, originColorMap]);
+
+ const totalPackets = useMemo(() => {
+ if (!originStats) {
+ return 0;
+ }
+
+ return Object.values(originStats.counts).reduce((sum, entry) => {
+ return sum + entry.reduce((innerSum, value) => innerSum + value, 0);
+ }, 0);
+ }, [originStats]);
+
+ const routingCombinedSeries = useMemo(() => {
+ if (!packetStats) {
+ return [];
+ }
+
+ const showRoutingBarLabels = xValues.length <= 20;
+
+ return [
+ {
+ id: 'routing-flood',
+ type: 'bar' as const,
+ label: 'Flood',
+ data: packetStats.routing.flood,
+ color: '#40A9FF',
+ stack: 'routing',
+ yAxisId: 'routing',
+ barLabel: showRoutingBarLabels ? ('value' as const) : undefined,
+ barLabelPlacement: 'outside' as const,
+ },
+ {
+ id: 'routing-direct',
+ type: 'bar' as const,
+ label: 'Direct',
+ data: packetStats.routing.direct,
+ color: '#73D13D',
+ stack: 'routing',
+ yAxisId: 'routing',
+ barLabel: showRoutingBarLabels ? ('value' as const) : undefined,
+ barLabelPlacement: 'outside' as const,
+ },
+ {
+ id: 'routing-transport',
+ type: 'line' as const,
+ label: 'Transport codes',
+ data: packetStats.routing.transport,
+ color: '#FAAD14',
+ showMark: xValues.length <= 30,
+ yAxisId: 'transport',
+ },
+ ];
+ }, [packetStats, xValues.length]);
+
+ const routingYAxisMax = useMemo(() => {
+ if (!packetStats) {
+ return 10;
+ }
+
+ const maxValue = Math.max(
+ ...packetStats.routing.flood,
+ ...packetStats.routing.direct,
+ 0
+ );
+
+ return Math.max(10, Math.ceil(maxValue * 1.2));
+ }, [packetStats]);
+
+ const transportYAxisMax = useMemo(() => {
+ if (!packetStats) {
+ return 10;
+ }
+
+ const maxValue = Math.max(...packetStats.routing.transport, 0);
+ return Math.max(10, Math.ceil(maxValue * 1.15));
+ }, [packetStats]);
+
+ const xTickStep = view === 'daily' ? 4 : view === 'weekly' ? 3 : 1;
+ const xTickStyle =
+ view === 'monthly'
+ ? { fontSize: 11 }
+ : { angle: -35, textAnchor: 'end' as const, fontSize: 11 };
+
+ const payloadSeries = useMemo(() => {
+ if (!packetStats) {
+ return [];
+ }
+
+ return Object.entries(packetStats.payload)
+ .sort(([a], [b]) => Number(a) - Number(b))
+ .map(([payloadType, data], idx) => ({
+ label: payloadTypeNameByCode[payloadType] ?? `type ${payloadType}`,
+ data,
+ stack: 'payload',
+ color: originPalette[idx % originPalette.length],
+ }));
+ }, [packetStats]);
+
+ return (
+
+ Origin Stats
+
+
+ Time Window
+ setView(event.target.value as MeshCoreStatsView)}
+ aria-label="Select MeshCore origin stats time window"
+ >
+ {(Object.keys(viewLabelByKey) as MeshCoreStatsView[]).map((key) => (
+
+ {viewLabelByKey[key]}
+
+ ))}
+
+
+
+
+
{descriptionByView[view]}
+
Origins: {originStats ? Object.keys(originStats.signal).length : 0}
+
Buckets: {originStats ? originStats.timestamps.length : 0}
+
Total Packets: {totalPackets}
+
+
+ {isLoading && (
+
+
+ Loading statistics...
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {(Object.keys(viewLabelByKey) as MeshCoreStatsView[]).map((key) => (
+ setView(key)}
+ >
+ {viewLabelByKey[key]}
+
+ ))}
+
+
+
+ )}
+ right={(
+
+ Origin & Packet Statistics
+
+ {isLoading ? (
+
+
+ Loading chart...
+
+ ) : null}
+
+ {!isLoading && !error && originStats && packetStats && xValues.length > 0 && signalSeries.length > 0 && series.length > 0 ? (
+
+
+
+
Origin Stats
+
+
+
Average Signal Strength (SNR dB / RSSI dBm)
+
+
+ formatTimestampLabel(Number(value)),
+ tickLabelInterval: (_, index) => index % xTickStep === 0,
+ tickLabelStyle: xTickStyle,
+ },
+ ]}
+ yAxis={[
+ {
+ id: 'snr',
+ width: 56,
+ },
+ {
+ id: 'rssi',
+ position: 'right',
+ width: 56,
+ },
+ ]}
+ series={signalSeries}
+ margin={{ top: 20, right: 16, bottom: 70, left: 16 }}
+ />
+
+
+
+
Legend
+
+ {origins.map((origin) => (
+
+ {origin || 'unknown'}
+
+
+ SNR
+
+ RSSI
+
+
+ ))}
+
+
+
+
+
+
+
Packet Count per Origin
+
+
+ formatTimestampLabel(Number(value)),
+ tickLabelInterval: (_, index) => index % xTickStep === 0,
+ tickLabelStyle: xTickStyle,
+ },
+ ]}
+ yAxis={[
+ {
+ id: 'count',
+ width: 56,
+ },
+ {
+ id: 'count-right-spacer',
+ position: 'right',
+ width: 56,
+ },
+ ]}
+ series={series.map((item) => ({ ...item, yAxisId: 'count' }))}
+ margin={{ top: 20, right: 16, bottom: 82, left: 16 }}
+ />
+
+
+
+
Origins
+
+ {origins.map((origin) => (
+
+
+ {origin || 'unknown'}
+
+ ))}
+
+
+
+
+
+
+
+
Packet Stats
+
+
+
Routing Types (Flood/Direct) with Transport Code Usage
+
+
+ formatTimestampLabel(Number(value)),
+ tickLabelInterval: (_, index) => index % xTickStep === 0,
+ tickLabelStyle: xTickStyle,
+ },
+ ]}
+ yAxis={[
+ {
+ id: 'routing',
+ label: 'Packets',
+ min: 0,
+ max: routingYAxisMax,
+ tickNumber: 6,
+ width: 56,
+ },
+ {
+ id: 'transport',
+ label: 'Transport code',
+ position: 'right',
+ min: 0,
+ max: transportYAxisMax,
+ tickNumber: 6,
+ width: 56,
+ },
+ ]}
+ margin={{ top: 20, right: 16, bottom: 82, left: 16 }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
Routing
+
+
+
+ Flood
+
+
+
+ Direct
+
+
+
+ Transport codes
+
+
+
+
+
+
+
+
Payload Types
+
+
+ formatTimestampLabel(Number(value)),
+ tickLabelInterval: (_, index) => index % xTickStep === 0,
+ tickLabelStyle: xTickStyle,
+ },
+ ]}
+ yAxis={[
+ {
+ id: 'payload',
+ width: 56,
+ },
+ {
+ id: 'payload-right-spacer',
+ position: 'right',
+ width: 56,
+ },
+ ]}
+ series={payloadSeries.map((item) => ({ ...item, yAxisId: 'payload' }))}
+ margin={{ top: 20, right: 16, bottom: 82, left: 16 }}
+ />
+
+
+
+
Payload
+
+ {payloadSeries.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+ ) : null}
+
+ {!isLoading && !error && (!originStats || !packetStats || xValues.length === 0 || series.length === 0) ? (
+
+ No origin statistics available for this time window.
+
+ ) : null}
+
+
+ )}
+ />
+ );
+};
+
+export default MeshCoreStatsView;
diff --git a/ui/src/pages/meshcore/MeshCoreView.tsx b/ui/src/pages/meshcore/MeshCoreView.tsx
new file mode 100644
index 0000000..447d77b
--- /dev/null
+++ b/ui/src/pages/meshcore/MeshCoreView.tsx
@@ -0,0 +1,516 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Alert, Badge, Card, Spinner } from 'react-bootstrap';
+import { useNavigate } from 'react-router';
+
+import { KPICard } from '../../components/protocol/KPICard';
+import { ProtocolHero } from '../../components/protocol/ProtocolHero';
+import { RadioListItem } from '../../components/protocol/RadioListItem';
+import { StatusPill } from '../../components/protocol/StatusPill';
+import { useRadios } from '../../contexts/RadiosContext';
+import API from '../../services/API';
+import '../../styles/ProtocolBriefing.scss';
+import MeshCoreServiceImpl, {
+ type MeshCoreOriginStats,
+ type MeshCorePacketStats,
+} from '../../services/MeshCoreService';
+import { PayloadType } from '../../types/protocol/meshcore.types';
+
+const meshCoreService = new MeshCoreServiceImpl(API);
+
+const payloadTypeNameByCode: Record = Object.entries(PayloadType).reduce(
+ (accumulator, [name, value]) => {
+ accumulator[String(value)] = name.toLowerCase().replace(/_/g, ' ');
+ return accumulator;
+ },
+ {} as Record
+);
+
+const sumSeries = (values: number[]): number => values.reduce((sum, value) => sum + value, 0);
+
+const getSignalHealth = (avgSnr: number): { label: string; tone: 'success' | 'warning' | 'danger' } => {
+ if (avgSnr >= 8) {
+ return { label: 'Strong', tone: 'success' };
+ }
+ if (avgSnr >= 4) {
+ return { label: 'Moderate', tone: 'warning' };
+ }
+ return { label: 'Weak', tone: 'danger' };
+};
+
+const getRoutingBehavior = (floodPercent: number): { label: string; tone: 'success' | 'warning' | 'danger' } => {
+ if (floodPercent <= 45) {
+ return { label: 'Efficient', tone: 'success' };
+ }
+ if (floodPercent <= 70) {
+ return { label: 'Mixed', tone: 'warning' };
+ }
+ return { label: 'Flood-heavy', tone: 'danger' };
+};
+
+const MeshCoreView: React.FC = () => {
+ const navigate = useNavigate();
+ const { radios, loading: radiosLoading, error: radiosError } = useRadios();
+ const [originStats, setOriginStats] = useState(null);
+ const [packetStats, setPacketStats] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const load = async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [originResponse, packetResponse] = await Promise.all([
+ meshCoreService.fetchOriginStats('weekly'),
+ meshCoreService.fetchPacketStats('weekly'),
+ ]);
+
+ if (!isMounted) {
+ return;
+ }
+
+ setOriginStats(originResponse);
+ setPacketStats(packetResponse);
+ } catch (err) {
+ if (!isMounted) {
+ return;
+ }
+ setError(err instanceof Error ? err.message : 'Failed to load MeshCore briefing metrics');
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void load();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ const snapshot = useMemo(() => {
+ if (!originStats || !packetStats) {
+ return null;
+ }
+
+ const originNames = Object.keys(originStats.signal);
+ const originsCount = originNames.length;
+
+ let totalPackets = 0;
+ let snrTotal = 0;
+ let rssiTotal = 0;
+ let signalSamples = 0;
+
+ originNames.forEach((origin) => {
+ totalPackets += sumSeries(originStats.counts[origin] ?? []);
+ const signal = originStats.signal[origin];
+ if (!signal) {
+ return;
+ }
+
+ signal.snr.forEach((value) => {
+ snrTotal += value;
+ signalSamples += 1;
+ });
+
+ signal.rssi.forEach((value) => {
+ rssiTotal += value;
+ });
+ });
+
+ const floodTotal = sumSeries(packetStats.routing.flood);
+ const directTotal = sumSeries(packetStats.routing.direct);
+ const routingTotal = floodTotal + directTotal;
+
+ const payloadTotals = Object.entries(packetStats.payload).map(([payloadType, values]) => ({
+ payloadType,
+ total: sumSeries(values),
+ }));
+
+ payloadTotals.sort((left, right) => right.total - left.total);
+
+ const topPayload = payloadTotals[0];
+
+ return {
+ buckets: originStats.timestamps.length,
+ originsCount,
+ totalPackets,
+ avgSnr: signalSamples > 0 ? snrTotal / signalSamples : 0,
+ avgRssi: signalSamples > 0 ? rssiTotal / signalSamples : 0,
+ floodPercent: routingTotal > 0 ? (floodTotal / routingTotal) * 100 : 0,
+ directPercent: routingTotal > 0 ? (directTotal / routingTotal) * 100 : 0,
+ topPayloadName: topPayload
+ ? (payloadTypeNameByCode[topPayload.payloadType] ?? `type ${topPayload.payloadType}`)
+ : 'unknown',
+ topPayloadTotal: topPayload?.total ?? 0,
+ };
+ }, [originStats, packetStats]);
+
+ const signalHealth = snapshot ? getSignalHealth(snapshot.avgSnr) : null;
+ const routingBehavior = snapshot ? getRoutingBehavior(snapshot.floodPercent) : null;
+
+ const meshCoreRadios = useMemo(() => {
+ return radios
+ .filter((radio) => radio.protocol === 'meshcore')
+ .sort((left, right) => {
+ if (left.is_online !== right.is_online) {
+ return left.is_online ? -1 : 1;
+ }
+ return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
+ });
+ }, [radios]);
+
+ const meshCoreOnlineCount = meshCoreRadios.filter((radio) => radio.is_online).length;
+
+ const openRadioPackets = (radioName: string) => {
+ navigate(`/meshcore/packets?radios=${encodeURIComponent(radioName)}`);
+ };
+
+ return (
+
+
+
+ MeshCore is a community-first digital mesh for amateur operators: low power, distributed,
+ and resilient when RF conditions are marginal or fixed infrastructure is unavailable.
+
+
+ Under the hood, links run on LoRa physical-layer radios, trading throughput for robust range
+ and better link margin. This page stays practical: path stability, relay pressure, and airtime behavior.
+ A LoRa modulation deep dive can follow later.
+
+ >
+ }
+ logoSrc="/image/protocol/meshcore.png"
+ logoAlt="MeshCore"
+ logoLabel="MeshCore Protocol"
+ statusPills={
+ !isLoading && !error && snapshot && signalHealth && routingBehavior ? (
+ <>
+
+
+ >
+ ) : undefined
+ }
+ />
+
+ {isLoading ? (
+
+
+
+ Loading operator briefing…
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {!isLoading && !error && snapshot ? (
+ <>
+
+
+
+
+ {snapshot.topPayloadName}}
+ description={`${snapshot.topPayloadTotal.toLocaleString()} packets`}
+ />
+
+
+
+
+ Registered MeshCore Radios
+ {meshCoreOnlineCount}/{meshCoreRadios.length} online
+
+
+ {radiosLoading ? (
+
+
+ Loading radio registry…
+
+ ) : null}
+
+ {!radiosLoading && radiosError ? (
+
+ {radiosError}
+
+ ) : null}
+
+ {!radiosLoading && !radiosError && meshCoreRadios.length === 0 ? (
+ No radios registered for MeshCore.
+ ) : null}
+
+ {!radiosLoading && !radiosError && meshCoreRadios.length > 0 ? (
+
+ {meshCoreRadios.map((radio) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+ How to Read This Network
+
+
+ Origins: quick proxy for station diversity and net activity spread.
+ SNR/RSSI: track link margin trends and near-term path stability.
+ Routing mix: flood-heavy windows can indicate higher relay load or weak direct paths.
+ Payload profile: shows airtime usage pattern (telemetry, chat, or control traffic).
+
+
+
+
+
+ Routing Snapshot
+
+
+ Flood traffic
+ {snapshot.floodPercent.toFixed(1)}%
+
+
+
+
+
+ Direct traffic
+ {snapshot.directPercent.toFixed(1)}%
+
+
+
+
+
+ Quick interpretation: a balanced split usually indicates stable neighborhood reachability and lower relay pressure.
+
+
+
+
+
+
+ LoRa in This Context
+ Regulatory note: check local rules
+
+
+
+
+
Current RF profile
+
+ 868.618 MHz center frequency, LoRa SF8, CR8, and 62.5 kHz bandwidth.
+
+
+
+
What it gives you
+
+ Long reach, better weak-signal decode behavior, and practical operation at low power.
+
+
+
+
Operational tradeoff
+
+ Lower throughput, so routing efficiency and payload discipline matter when airtime is busy.
+
+
+
+
What to monitor first
+
+ Origin diversity, flood/direct split, and sustained SNR/RSSI drift across the window.
+
+
+
+
ISM frequencies and licensing
+
+ The 868 MHz ISM band is generally intended for license-exempt low-power devices, so unlicensed operators can use
+ compliant LoRa equipment on ISM channels where regional rules permit. Amateur-service operation is separate and still
+ requires an amateur radio license, callsign use, and local band-plan compliance.
+
+
+
+
+
+
+
+
+ How MeshCore Operates on RF
+
+
+ MeshCore behaves like a cooperative RF neighborhood. Instead of one fixed path, packets move hop by hop through nearby nodes,
+ and the network converges toward delivery using direct and flood-style forwarding where needed.
+
+
+ In practice, quality is governed by geometry, channel conditions, and airtime pressure. When conditions are favorable,
+ delivery tends to stay direct. When links degrade or neighborhoods become sparse, forwarding expands and repeater participation
+ becomes more visible in routing statistics.
+
+
+ Known MeshCore Node Types
+
+
+
Chat Node
+
General endpoint for operator traffic such as messaging and routine interactive use.
+
+
+
Repeater
+
Coverage extension node that improves neighborhood reachability across RF gaps.
+
+
+
Room Server
+
Service-oriented node used for room/group coordination and shared interaction points.
+
+
+
Sensor
+
Telemetry-focused node optimized for periodic sensing and lightweight data reporting.
+
+
+
+
+ Note on data interpretation: Unknown in packet metadata is a temporary decode/classification state,
+ not a real operational node role.
+
+
+ Packet Journey Across RF
+
+ A source node emits a frame into its local neighborhood. Nearby peers evaluate whether the destination is directly reachable;
+ if yes, the packet stays on the shortest practical RF path. If not, forwarding broadens until connectivity is re-established.
+
+
+ Duplicate suppression prevents runaway retransmission. The practical outcome is convergence: multiple candidate paths may be heard,
+ but the network settles on useful delivery paths as conditions evolve.
+
+
+ Reference topology
+
+ {` [Chat Node A] -> [Repeater N] <=> [Repeater S] -> [Chat Node B]
+ | | |
+ [Sensor] [Room Server] [Sensor]
+
+ direct-dominant window => stronger local geometry, lower forwarding pressure
+ flood-dominant window => weaker links / topology transition / denser RF contention`}
+
+
+
+ Operator cue: if flood ratio climbs while SNR trends downward, treat it as a path-quality event first.
+ If payload volume rises while direct ratio remains stable, that usually indicates a traffic-shape shift more than RF degradation.
+
+
+
+
+
+ MeshCore Protocol Layers vs OSI
+
+
+ MeshCore is not a strict seven-layer OSI implementation, but operators can map its behavior to OSI concepts very effectively.
+ This mapping helps explain where RF ends, where forwarding decisions start, and where application payload meaning appears.
+
+
+ A practical way to read the protocol is as a compact four-layer model (Physical, Link, Network, Application)
+ and then map those functions to their nearest OSI equivalents.
+
+
+
+
+
+
MeshCore Layer
+
Physical Layer (LoRa PHY)
+
+
+ RF modulation and airtime profile: 868.618 MHz, SF8, CR8, 62.5 kHz bandwidth.
+
+
OSI: L1 Physical
+
+
+
+
+ Link Layer (local delivery behavior)
+
+
+ Neighbor-to-neighbor frame exchange, retries, and practical channel access constraints on shared RF.
+
+
OSI: L2 Data Link
+
+
+
+
+ Network Layer (mesh routing)
+
+
+ Route selection and forwarding via `DIRECT` / `FLOOD` with transport modes like `TRANSPORT_DIRECT` and `TRANSPORT_FLOOD`.
+
+
OSI: L3 (+ part of L4 semantics)
+
+
+
+
+ Application Layer (message semantics)
+
+
+ Payload types define meaning: `TEXT`, `GROUP_TEXT`, `CONTROL`, `PATH`, `ACK`, `ADVERT`, etc.
+
+
OSI: L7 (with L5/L6-like data handling)
+
+
+
+ Concrete OSI-style Examples
+
+
+
Example A · Group chat message
+
+ A `GROUP_TEXT` payload carries user content (OSI L7 meaning), while route type and transport flags determine whether
+ the frame moves directly or via flood-assisted forwarding (OSI L3/L4 behavior over RF).
+
+
+
+
+
Example B · Path visibility
+
+ A path-bearing packet (`PATH`/routing metadata) is primarily network-layer insight (OSI L3 analogue): it explains
+ how the packet traversed the mesh, independent of the user payload meaning.
+
+
+
+
+
Example C · RF degradation event
+
+ If RSSI/SNR drop (OSI L1 symptoms), you often observe increased flood ratio and fewer stable direct paths (OSI L3/L4
+ adaptation). Application traffic still exists, but efficiency and latency profile change.
+
+
+
+
+
+ >
+ ) : null}
+
+ );
+};
+
+export default MeshCoreView;
diff --git a/ui/src/protocols/adsb.ts b/ui/src/protocols/adsb.ts
new file mode 100644
index 0000000..003c602
--- /dev/null
+++ b/ui/src/protocols/adsb.ts
@@ -0,0 +1,358 @@
+import type {
+ Frame as IFrame,
+ DecodedPayload,
+ IdentificationPayload,
+ VelocityPayload,
+ SurfacePositionPayload,
+ AltitudePayload,
+} from '../types/protocol/adsb.types';
+import type { Segment } from '../types/protocol/dissection.types';
+import { ADSBMessageType, ADSBTypeCode } from '../types/protocol/adsb.types';
+
+export class ADSBFrame implements IFrame {
+ messageType: number = 0;
+ icao?: string;
+ typeCode?: number;
+ payload: DecodedPayload = { type: 'identification' };
+ raw: Uint8Array = new Uint8Array();
+ crc?: number;
+ segments?: Segment[];
+
+ private downlinkFormat: number = 0;
+ private data: Uint8Array = new Uint8Array();
+
+ /**
+ * Parse raw ADSB message
+ */
+ parse(raw: Uint8Array): void {
+ if (raw.length < 7) {
+ throw new Error('ADSB message too short');
+ }
+
+ this.raw = raw;
+ this.data = raw;
+
+ // Parse downlink format (first 5 bits)
+ this.downlinkFormat = (raw[0] >> 3) & 0x1f;
+ this.messageType = this.downlinkFormat;
+
+ // Parse ICAO address (24 bits, different positions based on DF)
+ this.icao = this.extractICAO();
+
+ // Parse based on downlink format
+ switch (this.downlinkFormat) {
+ case ADSBMessageType.IDENTIFICATION_REPLY:
+ case ADSBMessageType.SURVEILLANCE_REPLY:
+ // DF4/5: Short messages with limited data
+ this.parseShortMessage();
+ break;
+
+ case ADSBMessageType.IDENTIFICATION:
+ this.parseIdentification();
+ break;
+
+ case ADSBMessageType.SURVEILLANCE_POSITION:
+ case ADSBMessageType.SURVEILLANCE_POSITION_ALT:
+ // DF17/18: Extended squitter with type code
+ this.parseExtendedSquitter();
+ break;
+
+ default:
+ this.payload = { type: 'identification' };
+ break;
+ }
+ }
+
+ /**
+ * Decode the parsed ADSB message
+ */
+ decode(): DecodedPayload {
+ return this.payload;
+ }
+
+ /**
+ * Get ICAO address as hex string
+ */
+ private extractICAO(): string | undefined {
+ if (this.downlinkFormat === ADSBMessageType.IDENTIFICATION) {
+ // DF11: ICAO in bits 8-31
+ if (this.data.length >= 4) {
+ const addr = ((this.data[1] & 0xff) << 16) | ((this.data[2] & 0xff) << 8) | (this.data[3] & 0xff);
+ return addr.toString(16).padStart(6, '0').toUpperCase();
+ }
+ } else if (
+ this.downlinkFormat === ADSBMessageType.SURVEILLANCE_POSITION ||
+ this.downlinkFormat === ADSBMessageType.SURVEILLANCE_POSITION_ALT
+ ) {
+ // DF17/18: ICAO in bytes 1-3
+ if (this.data.length >= 4) {
+ const addr = ((this.data[1] & 0xff) << 16) | ((this.data[2] & 0xff) << 8) | (this.data[3] & 0xff);
+ return addr.toString(16).padStart(6, '0').toUpperCase();
+ }
+ }
+ return undefined;
+ }
+
+ /**
+ * Parse short message (DF4/5)
+ */
+ private parseShortMessage(): void {
+ const payload: IdentificationPayload = {
+ type: 'identification',
+ icao: this.icao,
+ };
+ this.payload = payload;
+ }
+
+ /**
+ * Parse identification message (DF11)
+ */
+ private parseIdentification(): void {
+ const payload: IdentificationPayload = {
+ type: 'identification',
+ icao: this.icao,
+ };
+ this.payload = payload;
+ }
+
+ /**
+ * Parse extended squitter (DF17/18)
+ */
+ private parseExtendedSquitter(): void {
+ if (this.data.length < 14) {
+ this.payload = { type: 'identification', icao: this.icao };
+ return;
+ }
+
+ // Type code is in bits 0-4 of byte 4
+ this.typeCode = (this.data[4] >> 3) & 0x1f;
+
+ switch (this.typeCode) {
+ case ADSBTypeCode.IDENTIFICATION_AND_CATEGORY:
+ this.parseIdentificationAndCategory();
+ break;
+
+ case ADSBTypeCode.SURFACE_POSITION:
+ this.parseSurfacePosition();
+ break;
+
+ case ADSBTypeCode.ALTITUDE_BAROMETRIC:
+ case ADSBTypeCode.ALTITUDE_GEOMETRIC:
+ this.parseAltitude();
+ break;
+
+ case ADSBTypeCode.AIRBORNE_VELOCITY:
+ this.parseVelocity();
+ break;
+
+ default:
+ this.payload = { type: 'identification', icao: this.icao };
+ break;
+ }
+ }
+
+ /**
+ * Parse identification and category (TC 1)
+ */
+ private parseIdentificationAndCategory(): void {
+ let callsign = '';
+ if (this.data.length >= 11) {
+ // Callsign is in bytes 5-10 (48 bits), 6 characters
+ const chars = 'US0-9#@ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
+ const bits = this.extractBits(40, 48);
+
+ for (let i = 0; i < 8; i++) {
+ const char = (bits >> (42 - i * 6)) & 0x3f;
+ if (char < chars.length) {
+ callsign += chars[char];
+ }
+ }
+ }
+
+ const payload: IdentificationPayload = {
+ type: 'identification',
+ icao: this.icao,
+ callsign: callsign.trim() || undefined,
+ category: this.getCategoryString((this.data[4] >> 0) & 0x07),
+ };
+
+ this.payload = payload;
+ }
+
+ /**
+ * Parse surface position (TC 5-8)
+ */
+ private parseSurfacePosition(): void {
+ const latitude = this.extractLatitude(40);
+ const longitude = this.extractLongitude(57);
+
+ const payload: SurfacePositionPayload = {
+ type: 'surface-position',
+ icao: this.icao,
+ latitude,
+ longitude,
+ groundSpeed: this.extractGroundSpeed(46),
+ trackAngle: this.extractTrackAngle(52),
+ };
+
+ this.payload = payload;
+ }
+
+ /**
+ * Parse barometric or geometric altitude (TC 11 or 20)
+ */
+ private parseAltitude(): void {
+ let altitudeType: 'barometric' | 'geometric' = 'barometric';
+ if (this.typeCode === ADSBTypeCode.ALTITUDE_GEOMETRIC) {
+ altitudeType = 'geometric';
+ }
+
+ const payload: AltitudePayload = {
+ type: 'altitude',
+ icao: this.icao,
+ altitude: this.extractAltitude(),
+ altitudeType,
+ };
+
+ this.payload = payload;
+ }
+
+ /**
+ * Parse velocity (TC 19)
+ */
+ private parseVelocity(): void {
+ const payload: VelocityPayload = {
+ type: 'velocity',
+ icao: this.icao,
+ groundSpeed: this.extractGroundSpeed(46),
+ trackAngle: this.extractTrackAngle(52),
+ verticalRate: this.extractVerticalRate(67),
+ };
+
+ this.payload = payload;
+ }
+
+ /**
+ * Extract bits from data array
+ */
+ private extractBits(startBit: number, length: number): number {
+ let value = 0;
+ for (let i = 0; i < length; i++) {
+ const bitIndex = startBit + i;
+ const byteIndex = Math.floor(bitIndex / 8);
+ const bitOffset = 7 - (bitIndex % 8);
+
+ if (byteIndex < this.data.length) {
+ const bit = (this.data[byteIndex] >> bitOffset) & 1;
+ value = (value << 1) | bit;
+ }
+ }
+ return value;
+ }
+
+ /**
+ * Extract latitude from compact position
+ */
+ private extractLatitude(startBit: number): number | undefined {
+ try {
+ const latBits = this.extractBits(startBit, 17);
+ // Simplified: convert 17 bits to latitude (-90 to 90)
+ return ((latBits / 65536) * 180) - 90;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Extract longitude from compact position
+ */
+ private extractLongitude(startBit: number): number | undefined {
+ try {
+ const lonBits = this.extractBits(startBit, 18);
+ // Simplified: convert 18 bits to longitude (-180 to 180)
+ return ((lonBits / 131072) * 360) - 180;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Extract ground speed (knots)
+ */
+ private extractGroundSpeed(startBit: number): number | undefined {
+ try {
+ const speedBits = this.extractBits(startBit, 10);
+ if (speedBits === 0) return undefined;
+ // Simplified speed calculation
+ return speedBits * 0.1;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Extract track angle (degrees)
+ */
+ private extractTrackAngle(startBit: number): number | undefined {
+ try {
+ const angleBits = this.extractBits(startBit, 10);
+ // Convert 10 bits to degrees (0-360)
+ return (angleBits / 1024) * 360;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Extract altitude (feet)
+ */
+ private extractAltitude(): number | undefined {
+ try {
+ if (this.data.length < 7) return undefined;
+
+ // Gillham code altitude extraction (D1-D11 bits)
+ const altBits = this.extractBits(40, 11);
+
+ // Simplified altitude calculation (gray code decoding omitted)
+ const altitude = ((altBits >> 1) & 0xff) * 100 + ((altBits & 1) * 100);
+ return altitude > 0 ? altitude : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Extract vertical rate (feet per minute)
+ */
+ private extractVerticalRate(startBit: number): number | undefined {
+ try {
+ const vrBits = this.extractBits(startBit, 9);
+ if (vrBits === 0) return undefined;
+ // Simplified vertical rate (100 ft/min per bit)
+ const sign = (vrBits & 0x100) ? -1 : 1;
+ return ((vrBits & 0xff) * 100) * sign;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Get aircraft category string
+ */
+ private getCategoryString(category: number): string {
+ const categories: Record = {
+ 0: 'No information',
+ 1: 'Light',
+ 2: 'Small',
+ 3: 'Large',
+ 4: 'High vortex',
+ 5: 'Heavy',
+ 6: 'High performance',
+ 7: 'Rotorcraft',
+ };
+ return categories[category] || 'Unknown';
+ }
+}
+
+// Re-export the Frame type for callers that import it from this module
+export type Frame = IFrame;
diff --git a/ui/src/protocols/aprs.ts b/ui/src/protocols/aprs.ts
index f75331d..9e436d4 100644
--- a/ui/src/protocols/aprs.ts
+++ b/ui/src/protocols/aprs.ts
@@ -740,7 +740,9 @@ export class Frame implements IFrame {
result.position.comment = comment;
}
- return { payload: result };
+ const section = emitSections ? undefined : undefined; // TODO: Build Mic-E sections when emitSections is supported
+
+ return { payload: result, sections: section };
} catch (e) {
return { payload: null };
}
diff --git a/ui/src/protocols/meshcore.test.ts b/ui/src/protocols/meshcore.test.ts
index ad8d057..62637bc 100644
--- a/ui/src/protocols/meshcore.test.ts
+++ b/ui/src/protocols/meshcore.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { bytesToHex } from '@noble/hashes/utils.js';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { GroupSecret, Packet } from './meshcore';
import type {
@@ -174,6 +174,40 @@ describe('GroupSecret', () => {
expect(secretFromKey.toHash()).not.toBe(secretFromName.toHash());
});
});
+
+ describe('Group Messages examples', () => {
+ it('should match channel hash and decrypt example for #bachelorette', () => {
+ const payloadHex = '5C13C031C6DC206E8B1D8D300C637FCE97204C8763A06B7AC406D39381DC0D07470C019D2047D711D48BAAE988EBBCB0966DC197A7DD99BDF154304B9E3AAA10498686';
+ const payload = hexToBytes(payloadHex);
+
+ const secret = GroupSecret.fromName('#bachelorette');
+
+ // channel hash (first byte) should match
+ expect(payload[0].toString(16).padStart(2, '0')).toBe(secret.toHash());
+
+ const cipherMAC = payload.slice(1, 3);
+ const cipherText = payload.slice(3);
+
+ const decrypted = secret.decrypt(cipherText, cipherMAC);
+ expect(decrypted.message).toBe('A1b2c3: This is a group message in #bachelorette!');
+ });
+
+ it('should match channel hash and decrypt example for PSK channel', () => {
+ const psk = '0378cebadd350b6c2b198f269eda1fd0';
+ const payloadHex = '3E4CA6AAA0C4866CE1ECD8D33C5792C0FCDAFE7C61CF477D86ECA6F5853BE0185A9385570512E585A7546DB5AC522B8531A3E30E165132D75C0CDC397C4094AC8BAFAA9679EF69B40882D7EA37911FE3D8576ECD3FB00B6F398DE92398D9B42548E486';
+ const payload = hexToBytes(payloadHex);
+
+ const secret = new GroupSecret(psk);
+
+ expect(payload[0].toString(16).padStart(2, '0')).toBe(secret.toHash());
+
+ const cipherMAC = payload.slice(1, 3);
+ const cipherText = payload.slice(3);
+
+ const decrypted = secret.decrypt(cipherText, cipherMAC);
+ expect(decrypted.message).toBe('A1b2c3: Hello in this group chat secure by random key, not hashtag key derivation!');
+ });
+ });
});
});
diff --git a/ui/src/protocols/meshcore.ts b/ui/src/protocols/meshcore.ts
index 6436abb..03999e3 100644
--- a/ui/src/protocols/meshcore.ts
+++ b/ui/src/protocols/meshcore.ts
@@ -16,7 +16,6 @@ import type {
BaseSharedSecret,
ControlPayload,
DecryptedGroupMessage,
- Group,
GroupDataPayload,
GroupSecretValue,
GroupTextPayload,
@@ -30,6 +29,7 @@ import type {
ResponsePayload,
TextPayload,
TracePayload,
+ DecryptedTextMessage,
} from '../types/protocol/meshcore.types';
// Local imports
@@ -38,9 +38,12 @@ import {
AdvertisementFlags,
BaseGroupSecret,
BasePacket,
+ payloadNameByValue,
PayloadType,
RouteType,
} from '../types/protocol/meshcore.types';
+import type { PacketMeta } from '../types/protocol.types';
+import { routeTypeNameByValue } from '../pages/meshcore/MeshCoreData';
const MAX_PATH_SIZE = 64;
@@ -56,50 +59,76 @@ export const hasTransportCodes = (routeType: RouteType): boolean => {
return routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
}
-export class Packet extends BasePacket {
- private _frameworkSection?: Segment;
+export class Packet extends BasePacket implements PacketMeta {
+ public routeType: RouteType;
+ public payloadVersion: number;
+ public payloadType: PayloadType;
+ public pathHashSize: number;
+ public pathHashCount: number;
+ public decodedPayload?: Payload;
+ public decrypted?: DecryptedTextMessage | DecryptedGroupMessage;
+ public segments?: Segment[];
- constructor(data?: Uint8Array | string, emitSections = false) {
- super();
+ constructor(
+ header: number,
+ transportCodes: Uint16Array | undefined,
+ pathLength: number,
+ path: Uint8Array,
+ payload: Uint8Array,
+ info: PacketMeta
+ ) {
+ super(header, transportCodes, pathLength, path, payload, info);
+ this.routeType = this.getRouteType();
+ this.payloadVersion = this.getPayloadVersion();
+ this.payloadType = this.getPayloadType();
+ this.pathHashSize = this.getPathHashSize();
+ this.pathHashCount = this.getPathHashCount();
+ }
+
+ public static fromBytes(
+ data: Uint8Array | string,
+ info: PacketMeta,
+ emitSections: boolean = true,
+ ): Packet {
if (typeof data !== 'undefined') {
if (typeof data === 'string') {
data = base64ToBytes(data);
}
- this.parse(data, emitSections);
}
- }
- public parse(data: Uint8Array, emitSections = false) {
let offset = 0;
- const frameworkChildren: Segment[] = [];
+ const segments: Segment[] = [];
const header = data[0];
- this.routeType = (header >> 0) & 0x03;
- this.payloadType = (header >> 2) & 0x0F;
- this.version = (header >> 6) & 0x03;
+ const version = (header >> 6) & 0x03;
+ const routeType = header & 0x03;
+ const payloadType = (header >> 2) & 0x0f;
// Build header section
if (emitSections) {
- frameworkChildren.push({
+ segments.push({
name: 'Header',
offset,
byteCount: 1,
- attributes: [
- { byteWidth: 1, type: 'uint8', name: 'Header Byte' },
+ bitfields: [
+ { offset: 6, length: 2, name: 'Payload Version' },
+ { offset: 2, length: 4, name: `Payload Type (${payloadNameByValue[payloadType]})` },
+ { offset: 0, length: 2, name: `Route Type (${routeTypeNameByValue[routeType]})` },
],
});
}
offset++;
let index = 1;
- if (hasTransportCodes(this.routeType)) {
+ let transportCodes: Uint16Array | undefined;
+ if (hasTransportCodes(routeType)) {
const view = new DataView(data.buffer, index, index + 4);
- this.transportCodes = new Uint16Array(2);
- this.transportCodes[0] = view.getUint16(0, true);
- this.transportCodes[1] = view.getUint16(2, true);
+ transportCodes = new Uint16Array(2);
+ transportCodes[0] = view.getUint16(0, true);
+ transportCodes[1] = view.getUint16(2, true);
if (emitSections) {
- frameworkChildren.push({
+ segments.push({
name: 'Transport Codes',
offset,
byteCount: 4,
@@ -113,39 +142,36 @@ export class Packet extends BasePacket {
offset += 4;
}
- this.pathLength = data[index];
- if (emitSections) {
- frameworkChildren.push({
- name: 'Path Length',
- offset,
- byteCount: 1,
- attributes: [
- { byteWidth: 1, type: 'uint8', name: 'Path Length' },
- ],
- });
- }
+ const pathLength = data[index];
index++;
offset++;
- if (!this.isValidPathLength()) {
- throw new Error(`MeshCore: invalid path length ${this.pathLength}`)
+ if (!this.isValidPathLength(pathLength)) {
+ throw new Error(`MeshCore: invalid path length ${pathLength}`)
}
- const pathBytesLength = this.getPathBytesLength();
- this.path = new Uint8Array(pathBytesLength);
+ const pathHashSize = (pathLength >> 6) + 1;
+ const pathHashCount = pathLength & 0x3f;
+ const pathBytesLength = pathHashSize * pathHashCount;
+ const path = new Uint8Array(pathBytesLength);
for (let i = 0; i < pathBytesLength; i++) {
- this.path[i] = data[index];
+ path[i] = data[index];
index++;
}
if (emitSections) {
- frameworkChildren.push({
+ segments.push({
name: 'Path',
- offset,
+ offset: offset - 1,
byteCount: pathBytesLength,
attributes: [
+ { byteWidth: 1, type: 'uint8', name: 'Path Length' },
{ byteWidth: pathBytesLength, type: 'uint8', name: 'Path Bytes' },
],
+ bitfields: [
+ { offset: 6, length: 2, name: `Hash Size Selector (size=${pathHashSize})` },
+ { offset: 0, length: 6, name: `Hash Count (count=${pathHashCount})` },
+ ],
});
}
offset += pathBytesLength;
@@ -154,123 +180,117 @@ export class Packet extends BasePacket {
throw new Error('MeshCore: invalid packet: no payload');
}
- if (emitSections) {
- this._frameworkSection = {
- name: 'Framework',
- offset: 0,
- byteCount: offset,
- children: frameworkChildren,
- };
- }
-
const payloadBytesLength = data.length - index;
- this.payload = new Uint8Array(payloadBytesLength);
+ const payload = new Uint8Array(payloadBytesLength);
for (let i = 0; i < payloadBytesLength; i++) {
- this.payload[i] = data[index];
+ payload[i] = data[index];
index++;
}
+ if (emitSections) {
+ segments.push({
+ name: 'Payload',
+ offset,
+ byteCount: payloadBytesLength,
+ attributes: [
+ { byteWidth: payloadBytesLength, type: 'uint8', name: 'Payload Bytes' },
+ ],
+ });
+ }
+
+ const packet = new Packet(header, transportCodes, pathLength, path, payload, info);
+ if (emitSections) {
+ packet.segments = segments;
+ packet.decodedPayload = packet.decode(true);
+ }
+
+ return packet;
}
- public parseBase64(data: string) {
- return this.parse(base64ToBytes(data));
- }
-
- public getFrameworkSection(): Segment | undefined {
- return this._frameworkSection;
- }
-
- private isValidPathLength(): boolean {
- const hashCount = this.getPathHashCount();
- const hashSize = this.getPathHashSize();
+ public static isValidPathLength(pathLength: number): boolean {
+ const hashCount = pathLength & 0x3F;
+ const hashSize = (pathLength >> 6) + 1;
if (hashSize === 4) return false; // reserved
return hashCount * hashSize <= MAX_PATH_SIZE;
}
- public getPathHashSize(): number {
- return (this.pathLength >> 6) + 1;
- }
-
- public getPathHashCount(): number {
- return this.pathLength & 63;
- }
-
- public getPathBytesLength(): number {
- return this.getPathHashCount() * this.getPathHashSize();
- }
-
- public hash(): Uint8Array {
- let data = new Uint8Array([this.payloadType]);
- if (this.payloadType === PayloadType.TRACE) {
+ public hash(): string {
+ const payloadType = this.getPayloadType();
+ let data = new Uint8Array([payloadType]);
+ if (payloadType === PayloadType.TRACE) {
data = new Uint8Array([...data, ...this.path]);
}
data = new Uint8Array([...data, ...this.payload]);
const hash = sha256.create().update(data).digest();
- return hash.slice(0, 8);
+ return bytesToHex(hash.slice(0, 8));
}
- public decode(): Payload;
- public decode(emitSections: true): { payload: Payload; sections?: Segment[] };
- public decode(emitSections = false): Payload | { payload: Payload; sections?: Segment[] } {
- let decodedPayload: Payload;
- let payloadSections: Segment[] | undefined;
+ public toBytes(): Uint8Array {
+ const headerBytes = new Uint8Array([this.header]);
+ const transportBytes = this.transportCodes ? new Uint8Array(this.transportCodes.buffer) : new Uint8Array();
+ const pathLengthByte = new Uint8Array([this.pathLength]);
+ const pathBytes = this.path;
+ const payloadBytes = this.payload;
+ return new Uint8Array([...headerBytes, ...transportBytes, ...pathLengthByte, ...pathBytes, ...payloadBytes]);
+ }
- switch (this.payloadType) {
+ public decode(emitSections: boolean = false): Payload {
+ if (typeof this.decodedPayload !== 'undefined' && this.decodedPayload !== null && this.decodedPayload.payloadType === this.getPayloadType()) {
+ return this.decodedPayload;
+ }
+
+ let payload: Payload;
+ let sections: Segment[] | undefined;
+ const payloadType = this.getPayloadType();
+
+ switch (payloadType) {
case PayloadType.REQUEST:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeRequest(emitSections));
+ ({ payload, sections } = this.decodeRequest(emitSections));
break;
case PayloadType.RESPONSE:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeResponse(emitSections));
+ ({ payload, sections } = this.decodeResponse(emitSections));
break;
case PayloadType.TEXT:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeText(emitSections));
+ ({ payload, sections } = this.decodeText(emitSections));
break;
case PayloadType.ACK:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeAck(emitSections));
+ ({ payload, sections } = this.decodeAck(emitSections));
break;
case PayloadType.ADVERT:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeAdvert(emitSections));
+ ({ payload, sections } = this.decodeAdvert(emitSections));
break;
case PayloadType.GROUP_TEXT:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupText(emitSections));
+ ({ payload, sections } = this.decodeGroupText(emitSections));
break;
case PayloadType.GROUP_DATA:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupData(emitSections));
+ ({ payload, sections } = this.decodeGroupData(emitSections));
break;
case PayloadType.ANON_REQ:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeAnonReq(emitSections));
+ ({ payload, sections } = this.decodeAnonReq(emitSections));
break;
case PayloadType.PATH:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodePath(emitSections));
+ ({ payload, sections } = this.decodePath(emitSections));
break;
case PayloadType.TRACE:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeTrace(emitSections));
+ ({ payload, sections } = this.decodeTrace(emitSections));
break;
case PayloadType.MULTIPART:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeMultipart(emitSections));
+ ({ payload, sections } = this.decodeMultipart(emitSections));
break;
case PayloadType.CONTROL:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeControl(emitSections));
+ ({ payload, sections } = this.decodeControl(emitSections));
break;
case PayloadType.RAW_CUSTOM:
- ({ payload: decodedPayload, sections: payloadSections } = this.decodeRawCustom(emitSections));
+ ({ payload, sections } = this.decodeRawCustom(emitSections));
break;
default:
- throw new Error(`MeshCore: can't decode payload ${this.payloadType}`)
+ throw new Error(`MeshCore: can't decode payload ${payloadType}`)
}
- if (!emitSections) {
- return decodedPayload;
+ if (emitSections) {
+ this.segments = this.segments ? [...this.segments, ...(sections ?? [])] : sections;
}
- const sections: Segment[] = [];
- if (this._frameworkSection) {
- sections.push(this._frameworkSection);
- }
- if (payloadSections) {
- sections.push(...payloadSections);
- }
-
- return { payload: decodedPayload, sections: sections.length > 0 ? sections : undefined };
+ return payload;
}
private decodeEncrypted(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } {
@@ -322,7 +342,30 @@ export class Packet extends BasePacket {
}
private decodeAck(emitSections = false): { payload: AckPayload; sections?: Segment[] } {
- return this.decodeEncrypted(PayloadType.ACK, emitSections);
+ let offset = 0;
+ const sections: Segment[] = [];
+ const buffer = new BufferReader(this.payload);
+
+ const checksum = buffer.readBytes(4);
+
+ if (emitSections) {
+ sections.push({
+ name: 'Ack Payload',
+ offset,
+ byteCount: this.payload.length,
+ attributes: [
+ { byteWidth: 4, type: 'uint8', name: 'Checksum' },
+ ],
+ });
+ }
+
+ return {
+ payload: {
+ payloadType: PayloadType.ACK,
+ checksum,
+ } as AckPayload,
+ sections: emitSections ? sections : undefined,
+ };
}
private decodeAdvert(emitSections = false): { payload: AdvertPayload; sections?: Segment[] } {
@@ -343,7 +386,14 @@ export class Packet extends BasePacket {
const flags = buffer.readUint8();
attributes.push({ byteWidth: 1, type: 'uint8', name: 'flags' });
- const appdata: { flags: number; latitude?: number; longitude?: number; feature1?: number; feature2?: number; name?: string } = {
+ const appdata: {
+ flags: number;
+ latitude?: number;
+ longitude?: number;
+ feature1?: number;
+ feature2?: number;
+ name?: string
+ } = {
flags,
};
@@ -374,7 +424,7 @@ export class Packet extends BasePacket {
if (emitSections) {
sections.push({
- name: 'Payload',
+ name: 'Advert Payload',
offset,
byteCount: this.payload.length,
attributes,
@@ -385,7 +435,7 @@ export class Packet extends BasePacket {
payload: {
payloadType: PayloadType.ADVERT,
publicKey,
- timestamp: timestampValue === 0 ? undefined : new Date(timestampValue),
+ timestamp: timestampValue === 0 ? undefined : new Date(timestampValue * 1000),
signature,
appdata,
},
@@ -476,7 +526,10 @@ export class Packet extends BasePacket {
private decodeTrace(emitSections = false): { payload: TracePayload; sections?: Segment[] } {
const sections: Segment[] = [];
const buffer = new BufferReader(this.payload);
- const data = buffer.readBytes();
+
+ const tag = buffer.readUint32LE();
+ const authCode = buffer.readBytes(4);
+ const nodes = buffer.readBytes();
if (emitSections) {
sections.push({
@@ -484,7 +537,9 @@ export class Packet extends BasePacket {
offset: 0,
byteCount: this.payload.length,
attributes: [
- { byteWidth: data.length, type: 'uint8', name: 'data' },
+ { byteWidth: 4, type: 'uint32le', name: 'Tag' },
+ { byteWidth: 4, type: 'uint8', name: 'Auth Code' },
+ { byteWidth: nodes.length, type: 'uint8', name: 'Nodes' },
],
});
}
@@ -492,7 +547,9 @@ export class Packet extends BasePacket {
return {
payload: {
payloadType: PayloadType.TRACE,
- data,
+ tag,
+ authCode,
+ nodes,
},
sections: emitSections ? sections : undefined,
};
@@ -679,7 +736,7 @@ export class SharedSecret implements BaseSharedSecret {
throw new Error(`invalid MAC`)
}
- const block = ecb(this.bytes);
+ const block = ecb(this.bytes.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(cipherText);
return plain;
@@ -711,6 +768,36 @@ export class StaticSecret {
}
}
+export class Group {
+ public name: string;
+ private secret: BaseGroupSecret;
+ protected isPublic: boolean;
+
+ constructor(name: string, secret?: string | Uint8Array | GroupSecret, isPublic: boolean = false) {
+ this.name = name;
+ this.isPublic = isPublic;
+ if (secret instanceof GroupSecret) {
+ this.secret = secret;
+ } else if (secret) {
+ this.secret = new GroupSecret(secret);
+ } else {
+ this.secret = GroupSecret.fromName(name);
+ }
+ }
+
+ public toHash(): string {
+ return this.secret.toHash();
+ }
+
+ public toString(): string {
+ return this.name;
+ }
+
+ public decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
+ return this.secret.decrypt(cipherText, cipherMAC);
+ }
+}
+
export class GroupSecret implements BaseGroupSecret {
private bytes: Uint8Array;
@@ -741,23 +828,24 @@ export class GroupSecret implements BaseGroupSecret {
}
toHash(): string {
- return this.bytes[0].toString(16).padStart(2, '0')
+ const h = sha256.create().update(this.bytes.slice(0, 16)).digest();
+ return h[0].toString(16).padStart(2, '0')
}
decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
- const ourMAC = hmac(sha256, this.bytes, cipherText)
+ const ourMAC = hmac(sha256, this.bytes, cipherText).slice(0, 2)
if (!constantTimeEqual(cipherMAC, ourMAC)) {
throw new Error('invalid MAC');
}
- const block = ecb(this.bytes);
+ const block = ecb(this.bytes.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(cipherText);
if (plain.length < 5) {
throw new Error('invalid payload');
}
const reader = new BufferReader(plain);
- const timestamp = new Date(reader.readUint32LE());
+ const timestamp = new Date(reader.readUint32LE() * 1000);
const flags = reader.readUint8();
let message = new TextDecoder('utf-8').decode(reader.readBytes());
const nullPos = message.indexOf('\0')
@@ -774,7 +862,7 @@ export class GroupSecret implements BaseGroupSecret {
static fromName(name: string): GroupSecret {
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
- return new GroupSecret(hash.slice(0, 32));
+ return new GroupSecret(hash.slice(0, 16));
}
}
@@ -790,17 +878,11 @@ export class KeyManager {
public addGroup(name: string, secret?: string | Uint8Array) {
let group: Group;
if (secret) {
- group = {
- name: name,
- secret: new GroupSecret(secret)
- }
+ group = new Group(name, new GroupSecret(secret));
} else {
- group = {
- name: name,
- secret: GroupSecret.fromName(name)
- }
+ group = new Group(name);
}
- const hash = group.secret.toHash();
+ const hash = group.toHash();
this.groups.set(hash, [...this.groups.get(hash) || [], group]);
}
@@ -813,7 +895,7 @@ export class KeyManager {
for (const group of this.groups.get(channelHash) || []) {
try {
return {
- ...group.secret.decrypt(cipherText, cipherMAC),
+ ...group.decrypt(cipherText, cipherMAC),
group: group.name
}
} catch (e) {
diff --git a/ui/src/services/ADSBService.ts b/ui/src/services/ADSBService.ts
new file mode 100644
index 0000000..66e1248
--- /dev/null
+++ b/ui/src/services/ADSBService.ts
@@ -0,0 +1,37 @@
+import type { APIService } from './API';
+import type { Packet } from '../types/protocol.types';
+
+export interface FetchedADSBPacket extends Packet {
+ id?: number;
+ radio_id?: number;
+ radio?: {
+ id?: number;
+ name?: string;
+ };
+ icao?: string;
+ callsign?: string;
+ latitude?: number;
+ longitude?: number;
+ altitude?: number;
+ ground_speed?: number;
+ track_angle?: number;
+ vertical_rate?: number;
+ raw?: string;
+ received_at?: string;
+}
+
+export class ADSBServiceImpl {
+ private api: APIService;
+
+ constructor(api: APIService) {
+ this.api = api;
+ }
+
+ public async fetchPackets(limit = 200): Promise {
+ const endpoint = '/adsb/packets';
+ const params = { limit };
+ return this.api.fetch(endpoint, { params });
+ }
+}
+
+export default ADSBServiceImpl;
diff --git a/ui/src/services/API.ts b/ui/src/services/API.ts
index 4e76cc9..1120b8f 100644
--- a/ui/src/services/API.ts
+++ b/ui/src/services/API.ts
@@ -1,6 +1,13 @@
import axios, { type AxiosRequestConfig } from 'axios';
import type { Radio } from '../types/radio.types';
+export interface Pager {
+ items: T[]; // Array of items for the current page
+ total: number; // Total number of items across all pages
+ page: number; // Current page number
+ limit: number; // Number of items per page
+}
+
export class APIService {
private readonly client;
@@ -18,6 +25,13 @@ export class APIService {
return response.data as T;
}
+ public async fetchPaginated(endpoint: string, page: number = 1, limit: number = 20): Promise> {
+ const response = await this.client.get>(endpoint, {
+ params: { page, limit },
+ });
+ return response.data as Pager;
+ }
+
public async fetchRadios(protocol?: string): Promise {
const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios';
return this.fetch(endpoint);
diff --git a/ui/src/services/MeshCoreService.ts b/ui/src/services/MeshCoreService.ts
index 598f7df..7e8eb66 100644
--- a/ui/src/services/MeshCoreService.ts
+++ b/ui/src/services/MeshCoreService.ts
@@ -1,22 +1,16 @@
-import type { APIService } from './API';
-import type { Group } from '../types/protocol/meshcore.types';
-import type { Packet } from '../types/protocol.types';
+import type { APIService, Pager } from './API';
+import type { GroupTextPayload, Payload } from '../types/protocol/meshcore.types';
+import { Packet, Group } from '../protocols/meshcore';
+import { base64ToBytes } from '../util';
+import type { KeyManager } from '../protocols/meshcore';
+// Removed duplicate type import `Packet` from types to avoid identifier clash
import { PayloadType } from '../types/protocol/meshcore.types';
import { GroupSecret } from '../protocols/meshcore';
+import { PacketInfo } from '../types/protocol.types';
-interface FetchedMeshCoreGroup {
- id: number;
- name: string;
- secret: string;
- isPublic: boolean;
-}
+// OLD
-export type MeshCoreGroupRecord = Group & {
- id: number;
- isPublic: boolean;
-};
-
-export interface FetchedMeshCorePacket extends Packet {
+export interface FetchedMeshCorePacket {
id: number;
radio_id: number;
version: number;
@@ -28,9 +22,85 @@ export interface FetchedMeshCorePacket extends Packet {
raw: string;
channel_hash: string;
received_at: string;
+ // Optional decrypted group payload (if provided by caller via KeyManager)
+ decrypted?: {
+ timestamp: string;
+ flags?: number;
+ message: string;
+ group?: string;
+ };
+ decodedPayload?: Payload;
}
-export class MeshCoreServiceImpl {
+export interface MeshCoreOriginSignalSeriesData {
+ snr: number[];
+ rssi: number[];
+}
+
+export interface MeshCoreOriginStats {
+ timestamps: number[];
+ signal: Record;
+ counts: Record;
+}
+
+export interface MeshCorePacketStats {
+ timestamps: number[];
+ routing: {
+ flood: number[];
+ direct: number[];
+ transport: number[];
+ };
+ payload: Record;
+}
+
+// NEW
+
+interface FetchedGroup {
+ id: number;
+ name: string;
+ secret: string;
+ isPublic: boolean;
+}
+
+interface FetchedNode {
+ id: number;
+ name: string;
+ type: number;
+ prefix: string;
+ public_key: string;
+ first_heard_at: string;
+ last_heard_at: string;
+ last_latitude?: number;
+ last_longitude?: number;
+}
+
+interface FetchedNodeDistance extends FetchedNode {
+ distance: number;
+}
+
+interface FetchedNodesCloseTo {
+ node: FetchedNode;
+ nodes: FetchedNodeDistance[];
+}
+
+interface FetchedPacket {
+ id: number;
+ radio_id: number;
+ snr: number;
+ rssi: number;
+ version: number;
+ route_type: number;
+ payload_type: number;
+ hash: string;
+ path: string;
+ payload: string;
+ raw: string;
+ parsed: any;
+ channel_hash: string;
+ received_at: string;
+}
+
+export class MeshCoreService {
private api: APIService;
constructor(api: APIService) {
@@ -41,16 +111,9 @@ export class MeshCoreServiceImpl {
* Fetch all available MeshCore groups
* @returns Array of Group objects with metadata
*/
- public async fetchGroups(): Promise {
- const groups = await this.api.fetch('/meshcore/groups');
- return groups.map((group) => ({
- id: group.id,
- name: group.name,
- secret: group.secret && group.secret.trim().length > 0
- ? new GroupSecret(group.secret)
- : GroupSecret.fromName(group.name),
- isPublic: group.isPublic,
- }));
+ public async fetchGroups(): Promise {
+ const groups = await this.api.fetch('/meshcore/groups');
+ return groups.map((group) => new Group(group.name, group.secret, group.isPublic));
}
/**
@@ -64,7 +127,8 @@ export class MeshCoreServiceImpl {
limit = 200,
type?: number,
channelHash?: string,
- ): Promise {
+ keyManager?: KeyManager,
+ ): Promise {
const endpoint = '/meshcore/packets';
const params: Record = { limit };
if (type !== undefined) {
@@ -73,7 +137,46 @@ export class MeshCoreServiceImpl {
if (channelHash !== undefined) {
params.channel_hash = channelHash;
}
- return this.api.fetch(endpoint, { params });
+
+ const fetchedPackets = await this.api.fetch(endpoint, { params });
+ const packets = fetchedPackets.map(p => {
+ // console.debug(`Fetched packet ${p.hash} with payload type ${p.payload_type} and route type ${p.route_type}`, { raw: p.raw, parsed: p.parsed });
+ return Packet.fromBytes(base64ToBytes(p.raw), new PacketInfo({
+ receivedAt: new Date(p.received_at),
+ snr: p.snr,
+ rssi: p.rssi,
+ radioName: undefined, // Assuming radioName is not provided in the API response
+ }));
+ });
+
+ if (!keyManager) return packets;
+
+ // Attempt to decrypt any group payloads using provided KeyManager
+ for (const packet of packets) {
+ try {
+ const payload = packet.decode(true);
+ packet.decodedPayload = payload;
+ // console.log('Decoded packet payload', { payload, payloadType: packet.payloadType });
+ try {
+ switch (packet.payloadType) {
+ case PayloadType.GROUP_TEXT:
+ const groupText = payload as GroupTextPayload;
+ packet.decrypted = keyManager.decryptGroup(groupText.channelHash, groupText.cipherText, groupText.cipherMAC);
+ break;
+ case PayloadType.GROUP_DATA:
+ const groupData = payload as GroupTextPayload; // Assuming GROUP_DATA has same structure as GROUP_TEXT for decryption
+ packet.decrypted = keyManager.decryptGroup(groupData.channelHash, groupData.cipherText, groupData.cipherMAC);
+ break;
+ }
+ } catch {
+ // ignore decryption failures
+ }
+ } catch (e) {
+ console.warn('Failed to decode packet payload', e);
+ }
+ }
+
+ return packets;
}
/**
@@ -81,9 +184,45 @@ export class MeshCoreServiceImpl {
* @param channelHash The channel hash to fetch packets for
* @returns Array of raw packet data
*/
- public async fetchGroupPackets(channelHash: string): Promise {
- return this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash);
+ public async fetchGroupPackets(channelHash: string, keyManager?: KeyManager): Promise {
+ const packets = await this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash);
+
+ if (!keyManager) return packets;
+
+ for (const packet of packets) {
+ const payload = packet.decodedPayload;
+ if (payload && 'cipherText' in payload && 'cipherMAC' in payload && 'channelHash' in payload) {
+ try {
+ const dec = keyManager.decryptGroup(
+ (payload as any).channelHash,
+ (payload as any).cipherText,
+ (payload as any).cipherMAC
+ );
+ packet.decrypted = dec;
+ } catch (e) {
+ // ignore decryption failures
+ }
+ }
+ }
+
+ return packets;
}
+
+ public async fetchOriginStats(view: 'daily' | 'weekly' | 'monthly'): Promise {
+ return this.api.fetch(`/meshcore/stats/origins/${encodeURIComponent(view)}`);
+ }
+
+ public async fetchPacketStats(view: 'daily' | 'weekly' | 'monthly'): Promise {
+ return this.api.fetch(`/meshcore/stats/packets/${encodeURIComponent(view)}`);
+ }
+
+ public async fetchNodes(page: number = 1, limit: number = 200): Promise> {
+ return this.api.fetchPaginated('/meshcore/nodes', page, limit);
+ }
+
+ public async fetchNodesCloseTo(hash: string): Promise {
+ return this.api.fetch(`/meshcore/nodes/close-to/${encodeURIComponent(hash)}`);
+ }
}
-export default MeshCoreServiceImpl;
+export default MeshCoreService;
diff --git a/ui/src/services/MeshCoreStream.ts b/ui/src/services/MeshCoreStream.ts
index 76f2c74..0625937 100644
--- a/ui/src/services/MeshCoreStream.ts
+++ b/ui/src/services/MeshCoreStream.ts
@@ -1,55 +1,29 @@
-import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
-import { Packet as MeshCorePacket } from '../protocols/meshcore';
-import type { Payload } from '../types/protocol/meshcore.types';
-import type { Packet } from '../types/protocol.types';
+import { Packet, KeyManager } from '../protocols/meshcore';
+import { PacketInfo } from '../types/protocol.types';
import { BaseStream } from './Stream';
+import { base64ToBytes } from '../util';
-export interface MeshCoreMessage extends Packet {
- topic: string;
- raw: Uint8Array;
- hash: string;
- decodedPayload?: Payload;
-}
-
-interface MeshCoreJsonEnvelope {
- payloadBase64?: string;
- payloadHex?: string;
- snr?: number;
+interface StreamPacket {
+ time: string;
+ raw: string;
+ snr?: number;
rssi?: number;
}
export class MeshCoreStream extends BaseStream {
+ public keyManager: KeyManager = new KeyManager();
constructor(autoConnect = false) {
super({}, autoConnect);
}
- protected decodeMessage(topic: string, payload: Uint8Array): MeshCoreMessage {
- const { bytes: packetBytes, snr, rssi } = this.extractPacketBytes(payload);
- const parsed = new MeshCorePacket();
- parsed.parse(packetBytes);
-
- let decodedPayload: Payload | undefined;
- try {
- decodedPayload = parsed.decode();
- } catch {
- decodedPayload = undefined;
- }
-
- console.log('parsed packet', parsed, { decodedPayload });
-
- // Extract radio name from topic: meshcore/packet/
- const radioName = this.extractRadioNameFromTopic(topic);
-
- return {
- topic,
- receivedAt: new Date(),
- raw: packetBytes,
- hash: bytesToHex(parsed.hash()),
- decodedPayload,
- radioName,
- snr,
- rssi,
- };
+ protected decodeMessage(topic: string, payload: Uint8Array): Packet {
+ const { bytes: packetBytes, time, snr, rssi } = this.extractPacketBytes(payload);
+ return Packet.fromBytes(packetBytes, new PacketInfo({
+ receivedAt: time || new Date(),
+ snr: snr,
+ rssi: rssi,
+ radioName: this.extractRadioNameFromTopic(topic),
+ }));
}
private extractRadioNameFromTopic(topic: string): string | undefined {
@@ -67,25 +41,31 @@ export class MeshCoreStream extends BaseStream {
return undefined;
}
- private extractPacketBytes(payload: Uint8Array): { bytes: Uint8Array; snr?: number; rssi?: number } {
+ private extractPacketBytes(payload: Uint8Array): { bytes: Uint8Array; snr?: number; rssi?: number; time?: Date } {
const text = new TextDecoder().decode(payload).trim();
if (!text.startsWith('{')) {
return { bytes: payload };
}
- const envelope = JSON.parse(text) as MeshCoreJsonEnvelope;
+ const envelope = JSON.parse(text) as StreamPacket;
let bytes: Uint8Array = payload;
+ let time: Date | undefined = undefined;
- if (envelope.payloadBase64) {
- bytes = Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0));
- } else if (envelope.payloadHex) {
- bytes = hexToBytes(envelope.payloadHex);
+ if (envelope.raw) {
+ bytes = base64ToBytes(envelope.raw);
+ }
+
+ if (envelope.time) {
+ time = new Date(envelope.time);
}
return {
bytes,
+ time,
snr: envelope.snr,
rssi: envelope.rssi,
};
}
}
+
+export default MeshCoreStream;
diff --git a/ui/src/services/Stream.ts b/ui/src/services/Stream.ts
index c5ac5da..88cf129 100644
--- a/ui/src/services/Stream.ts
+++ b/ui/src/services/Stream.ts
@@ -2,9 +2,9 @@ import mqtt from "mqtt";
import type { StreamConnectionOptions, StreamState, TopicSubscription } from "../types/stream.types";
const defaultConnectionOptions: StreamConnectionOptions = {
- url: import.meta.env.DEV
- ? 'ws://10.42.23.73:8083'
- : ((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
+ url: import.meta.env.DEV ?
+ 'wss://pd0mz.hamnet.nl/broker' :
+ ((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
}
export abstract class BaseStream {
@@ -12,7 +12,7 @@ export abstract class BaseStream {
protected connectionOptions: StreamConnectionOptions;
protected subscribers: Map void>> = new Map();
protected stateSubscribers: Set<(state: StreamState) => void> = new Set();
- protected reconnectTimer: NodeJS.Timeout | null = null;
+ protected reconnectTimer: ReturnType | null = null;
protected autoConnect: boolean;
protected state: StreamState = {
@@ -48,7 +48,9 @@ export abstract class BaseStream {
try {
const randomId = Math.random().toString(16).slice(2, 10);
const prefix = import.meta.env.DEV ? 'dev_' : '';
- const defaultClientId = `${prefix}hamview_${randomId}`;
+ const defaultClientId = `${prefix}web_${randomId}`;
+
+ console.log(`Connecting to MQTT broker at ${this.connectionOptions.url} with clientId ${defaultClientId}`);
this.client = mqtt.connect(this.connectionOptions.url, {
...this.connectionOptions.options,
diff --git a/ui/src/styles/ProtocolBriefing.scss b/ui/src/styles/ProtocolBriefing.scss
new file mode 100644
index 0000000..9a350b3
--- /dev/null
+++ b/ui/src/styles/ProtocolBriefing.scss
@@ -0,0 +1,277 @@
+// Shared briefing styles for protocol landing pages (APRS, MeshCore, etc.)
+
+.protocol-briefing {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 0.5rem;
+}
+
+.protocol-briefing-hero {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid rgba(173, 205, 255, 0.28);
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(120deg, rgba(64, 169, 255, 0.12), rgba(147, 197, 253, 0.04));
+ pointer-events: none;
+ }
+
+ .card-body {
+ position: relative;
+ z-index: 1;
+ }
+
+ h2 {
+ color: var(--app-text);
+ font-size: 1.5rem;
+ letter-spacing: 0.01em;
+ }
+}
+
+.protocol-briefing-hero-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 1rem;
+ align-items: center;
+}
+
+.protocol-briefing-hero-content {
+ min-width: 0;
+}
+
+.protocol-briefing-hero-logo-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ min-width: 220px;
+}
+
+.protocol-briefing-hero-logo {
+ width: 100%;
+ max-width: 180px;
+ height: auto;
+ aspect-ratio: 1 / 1;
+ object-fit: contain;
+ border-radius: 12px;
+ border: 1px solid rgba(173, 205, 255, 0.28);
+ background: rgba(9, 31, 66, 0.55);
+ padding: 0.55rem;
+ box-shadow: 0 10px 20px rgba(2, 10, 26, 0.35);
+}
+
+.protocol-briefing-hero-logo-label {
+ color: var(--app-text-muted);
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.protocol-briefing-signal-row {
+ margin-top: 0.85rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem;
+}
+
+.protocol-briefing-signal-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ border: 1px solid rgba(173, 205, 255, 0.25);
+ border-radius: 999px;
+ padding: 0.35rem 0.6rem;
+ background: rgba(11, 39, 82, 0.38);
+ color: var(--app-text-muted);
+ font-size: 0.82rem;
+}
+
+.protocol-briefing-kpis {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.75rem;
+}
+
+.protocol-briefing-kpi-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ border-left: 3px solid rgba(64, 169, 255, 0.75);
+ background: linear-gradient(180deg, rgba(13, 36, 82, 0.42), rgba(13, 36, 82, 0.14));
+}
+
+.protocol-briefing-kpis .data-table-card:nth-child(2) .protocol-briefing-kpi-card {
+ border-left-color: rgba(115, 209, 61, 0.8);
+}
+
+.protocol-briefing-kpis .data-table-card:nth-child(3) .protocol-briefing-kpi-card {
+ border-left-color: rgba(250, 173, 20, 0.82);
+}
+
+.protocol-briefing-kpis .data-table-card:nth-child(4) .protocol-briefing-kpi-card {
+ border-left-color: rgba(146, 84, 222, 0.82);
+}
+
+.protocol-briefing-kpi-label {
+ color: var(--app-text-muted);
+ font-size: 0.78rem;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+
+.protocol-briefing-kpi-value {
+ font-size: 1.35rem;
+ font-weight: 700;
+ color: var(--app-text);
+ line-height: 1.2;
+ margin: 0.2rem 0 0.35rem;
+}
+
+.protocol-briefing-radio-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 0.55rem;
+}
+
+.protocol-briefing-radio-item {
+ border: 1px solid rgba(173, 205, 255, 0.22);
+ border-radius: 8px;
+ padding: 0.5rem 0.6rem;
+ background: rgba(11, 39, 82, 0.22);
+}
+
+.protocol-briefing-radio-item--clickable {
+ cursor: pointer;
+ transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
+
+ &:hover,
+ &:focus-visible {
+ border-color: rgba(173, 205, 255, 0.55);
+ background: rgba(11, 39, 82, 0.38);
+ transform: translateY(-1px);
+ }
+}
+
+.protocol-briefing-radio-main {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ color: var(--app-text);
+ min-width: 0;
+
+ .badge {
+ margin-left: 0.4rem;
+ flex: 0 0 auto;
+ }
+}
+
+.protocol-briefing-radio-image {
+ width: 3.2rem;
+ height: 3.2rem;
+ border-radius: 6px;
+ object-fit: contain;
+ border: 1px solid rgba(173, 205, 255, 0.25);
+ background: rgba(9, 31, 66, 0.45);
+ padding: 0.35rem;
+ flex: 0 0 auto;
+}
+
+.protocol-briefing-radio-title {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 0.05rem;
+ flex: 1 1 auto;
+
+ strong {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.protocol-briefing-radio-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ flex: 0 0 auto;
+
+ &.is-online {
+ background: #73d13d;
+ box-shadow: 0 0 0 2px rgba(115, 209, 61, 0.2);
+ }
+
+ &.is-offline {
+ background: #8c8c8c;
+ box-shadow: 0 0 0 2px rgba(140, 140, 140, 0.2);
+ }
+}
+
+.protocol-briefing-radio-meta {
+ font-size: 0.82rem;
+ line-height: 1.3;
+ color: var(--app-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.protocol-briefing-columns {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 0.75rem;
+}
+
+.protocol-briefing-list {
+ color: var(--app-text-muted);
+ padding-left: 1.1rem;
+ margin: 0;
+
+ li {
+ margin-bottom: 0.45rem;
+ }
+
+ li + li {
+ margin-top: 0.35rem;
+ }
+
+ strong {
+ color: var(--app-text);
+ }
+}
+
+@media (max-width: 1200px) {
+ .protocol-briefing-kpis {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 992px) {
+ .protocol-briefing-hero-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .protocol-briefing-hero-logo-wrap {
+ align-items: flex-start;
+ min-width: 0;
+ }
+
+ .protocol-briefing-columns {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .protocol-briefing {
+ padding: 0;
+ }
+
+ .protocol-briefing-kpis {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/ui/src/styles/_theme.scss b/ui/src/styles/_theme.scss
index 13db7fe..08a4547 100644
--- a/ui/src/styles/_theme.scss
+++ b/ui/src/styles/_theme.scss
@@ -2,11 +2,13 @@
THEME STYLES - Reusable Typography & Colors
============================================ */
-@import './theme/typography';
-@import './theme/buttons';
-@import './theme/badges';
-@import './theme/tags';
-@import './theme/forms';
-@import './theme/code';
-@import './theme/tables';
-@import './theme/utilities';
+@use './theme/typography' as *;
+@use './theme/buttons' as *;
+@use './theme/badges' as *;
+@use './theme/tags' as *;
+@use './theme/forms' as *;
+@use './theme/code' as *;
+@use './theme/tables' as *;
+@use './theme/utilities' as *;
+@use './theme/charts' as *;
+@use './theme/bootstrap-overrides' as *;
diff --git a/ui/src/styles/theme/_bootstrap-overrides.scss b/ui/src/styles/theme/_bootstrap-overrides.scss
new file mode 100644
index 0000000..0b2e52a
--- /dev/null
+++ b/ui/src/styles/theme/_bootstrap-overrides.scss
@@ -0,0 +1,69 @@
+// Bootstrap font size overrides for smaller default text
+html, body {
+ font-size: 14px;
+}
+
+// Optionally, adjust headings and other elements if needed
+h1 { font-size: 2rem; }
+h2 { font-size: 1.5rem; }
+h3 { font-size: 1.17rem; }
+h4 { font-size: 1rem; }
+h5 { font-size: 0.83rem; }
+h6 { font-size: 0.67rem; }
+
+// Bootstrap utility classes
+.small, .text-small, .form-text, .form-label, .form-control, .btn, .nav-link, .dropdown-item {
+ font-size: 0.92em;
+}
+
+// Bootstrap table color variable overrides for dark blue theme
+:root, .table {
+ --bs-table-bg: #162447;
+ --bs-table-striped-bg: #1f4068;
+ --bs-table-striped-color: #f8f9fa;
+ --bs-table-active-bg: #23395d;
+ --bs-table-active-color: #f8f9fa;
+ --bs-table-hover-bg: #23395d;
+ --bs-table-hover-color: #f8f9fa;
+ --bs-table-border-color: #23395d;
+ --bs-table-color: #f8f9fa;
+}
+
+// Default table style for dark blue theme
+table {
+ background-color: #162447;
+ color: #f8f9fa;
+}
+
+th, td {
+ background-color: inherit;
+ color: inherit;
+}
+
+thead {
+ background-color: #1f4068;
+}
+
+tbody tr {
+ border-bottom: 1px solid #23395d;
+}
+
+// Make bootstrap monospace utility and code elements slightly smaller
+.font-monospace,
+code,
+kbd,
+samp {
+ font-size: 0.75rem;
+ line-height: 1;
+ vertical-align: middle;
+ color: var(--app-accent);
+}
+
+pre {
+ font-size: 0.75rem;
+ line-height: 1.2;
+}
+
+.main-content a {
+ color: var(--app-accent-yellow) !important;
+}
diff --git a/ui/src/styles/theme/_charts.scss b/ui/src/styles/theme/_charts.scss
new file mode 100644
index 0000000..509ee9e
--- /dev/null
+++ b/ui/src/styles/theme/_charts.scss
@@ -0,0 +1,47 @@
+/* ============================================
+ CHARTS - Global defaults for MUI X Charts
+ ============================================ */
+
+.MuiChartsSurface-root,
+.MuiChartsWrapper-root {
+ color: var(--app-text-muted) !important;
+}
+
+.MuiChartsAxis-line,
+.MuiChartsAxis-tick,
+.MuiChartsGrid-line {
+ stroke: rgba(173, 205, 255, 0.32) !important;
+}
+
+.MuiChartsAxis-tickLabel,
+.MuiChartsLegend-label,
+.MuiChartsAxis-label,
+.MuiChartsLabel-root {
+ fill: var(--app-text-muted) !important;
+}
+
+.MuiChartsTooltip-root,
+.MuiChartsTooltip-paper,
+.MuiChartsTooltip-table,
+.MuiChartsTooltip-row,
+.MuiChartsTooltip-cell,
+.MuiChartsTooltip-labelCell,
+.MuiChartsTooltip-valueCell,
+.MuiChartsTooltip-axisValueCell {
+ color: var(--app-text-muted) !important;
+}
+
+.MuiChartsLegend-root {
+ background: rgba(11, 39, 82, 0.45);
+ border: 1px solid rgba(173, 205, 255, 0.2);
+ border-radius: 8px;
+ padding: 0.35rem 0.5rem;
+}
+
+.MuiChartsLegend-series {
+ margin-right: 0.75rem;
+}
+
+.MuiChartsLegend-mark {
+ filter: drop-shadow(0 0 3px rgba(2, 10, 26, 0.6));
+}
diff --git a/ui/src/styles/theme/_tables.scss b/ui/src/styles/theme/_tables.scss
index 005c6f2..db37fa3 100644
--- a/ui/src/styles/theme/_tables.scss
+++ b/ui/src/styles/theme/_tables.scss
@@ -32,42 +32,5 @@
}
.data-table {
- color: var(--app-text);
-
- thead th {
- position: sticky;
- top: 0;
- z-index: 2;
- background: rgba(13, 36, 82, 0.95);
- border-color: rgba(173, 205, 255, 0.18);
- color: var(--app-text);
- }
-
- td {
- border-color: rgba(173, 205, 255, 0.12);
- vertical-align: middle;
- cursor: pointer;
- }
-
- tr.is-selected td {
- background: rgba(102, 157, 255, 0.34);
- color: #f2f7ff;
- }
-
- tr.is-selected a,
- tr.is-selected a:hover {
- color: var(--app-accent-yellow);
- }
-
- tr:hover td {
- background: rgba(102, 157, 255, 0.08);
- color: var(--app-text);
- }
-
- tr:hover .callsign {
- color: var(--app-accent-yellow);
- background-color: var(--app-blue-dark);
- border-color: var(--app-accent-yellow);
- }
-
+ /* Color and background overrides removed to use default/inherited styles */
}
diff --git a/ui/src/types/layout.types.tsx b/ui/src/types/layout.types.tsx
index a8e7aa0..626452b 100644
--- a/ui/src/types/layout.types.tsx
+++ b/ui/src/types/layout.types.tsx
@@ -20,7 +20,7 @@ export interface FullProps {
className?: string;
}
-export type VerticalSplitRatio = '1:1' | '3:1' | '2:1' | '25/70';
+export type VerticalSplitRatio = '1:1' | '3:1' | '2:1' | '25/70' | '25:75';
export interface VerticalSplitProps {
left?: React.ReactNode;
diff --git a/ui/src/types/protocol.types.ts b/ui/src/types/protocol.types.ts
index ec5577c..daa6246 100644
--- a/ui/src/types/protocol.types.ts
+++ b/ui/src/types/protocol.types.ts
@@ -33,7 +33,7 @@ export const toModulationDisplayName = (modulation: string): string => {
* Base packet interface with common fields from all protocol types.
* These fields are typically extracted from Go's protocol.Packet struct.
*/
-export interface Packet {
+export interface PacketMeta {
/** When the packet was received */
receivedAt: Date;
/** Signal-to-Noise Ratio in dB */
@@ -43,3 +43,17 @@ export interface Packet {
/** Name/ID of the radio that received the packet */
radioName?: string;
}
+
+export class PacketInfo implements PacketMeta {
+ public receivedAt: Date = new Date();
+ public snr?: number;
+ public rssi?: number;
+ public radioName?: string;
+
+ constructor(meta: PacketMeta) {
+ this.receivedAt = meta.receivedAt;
+ this.snr = meta.snr;
+ this.rssi = meta.rssi;
+ this.radioName = meta.radioName;
+ }
+}
diff --git a/ui/src/types/protocol/adsb.types.ts b/ui/src/types/protocol/adsb.types.ts
new file mode 100644
index 0000000..3b2f509
--- /dev/null
+++ b/ui/src/types/protocol/adsb.types.ts
@@ -0,0 +1,129 @@
+import type { Segment } from './dissection.types';
+
+/**
+ * ADSB Message Types (DF - Downlink Format)
+ */
+export const ADSBMessageType = {
+ SURVEILLANCE_REPLY: 0, // DF0
+ IDENTIFICATION_REPLY: 4, // DF4
+ SURVEILLANCE_ALTITUDE: 5, // DF5
+ IDENTIFICATION: 11, // DF11
+ SURVEILLANCE_POSITION: 17, // DF17
+ SURVEILLANCE_POSITION_ALT: 18, // DF18
+} as const;
+
+export type ADSBMessageType = typeof ADSBMessageType[keyof typeof ADSBMessageType] | number;
+
+/**
+ * ADSB TC (Type Code) for DF17/18
+ */
+export const ADSBTypeCode = {
+ IDENTIFICATION_AND_CATEGORY: 1,
+ SURFACE_POSITION: 5,
+ ALTITUDE_BAROMETRIC: 11,
+ ALTITUDE_GEOMETRIC: 20,
+ AIRBORNE_VELOCITY: 19,
+} as const;
+
+export type ADSBTypeCode = typeof ADSBTypeCode[keyof typeof ADSBTypeCode] | number;
+
+/**
+ * Aircraft identification payload
+ */
+export interface IdentificationPayload {
+ type: 'identification';
+ callsign?: string; // Aircraft identification
+ category?: string; // Aircraft category
+ icao?: string; // ICAO address
+ sections?: Segment[];
+}
+
+/**
+ * Position payload (surface or airborne)
+ */
+export interface PositionPayload {
+ type: 'position';
+ icao?: string; // ICAO address
+ latitude?: number; // Decimal degrees
+ longitude?: number; // Decimal degrees
+ altitude?: number; // Feet
+ altitudeType?: 'barometric' | 'geometric';
+ groundSpeed?: number; // Knots
+ trackAngle?: number; // Degrees
+ verticalRate?: number; // Feet per minute
+ onGround?: boolean; // Whether aircraft is on ground
+ sections?: Segment[];
+}
+
+/**
+ * Velocity payload
+ */
+export interface VelocityPayload {
+ type: 'velocity';
+ icao?: string; // ICAO address
+ groundSpeed?: number; // Knots
+ trackAngle?: number; // Degrees (true north)
+ verticalRate?: number; // Feet per minute
+ verticalRateSource?: 'barometric' | 'geometric';
+ headingType?: 'computed' | 'magnetic';
+ speedSource?: 'computed' | 'gps';
+ sections?: Segment[];
+}
+
+/**
+ * Surface position payload
+ */
+export interface SurfacePositionPayload {
+ type: 'surface-position';
+ icao?: string; // ICAO address
+ latitude?: number; // Decimal degrees
+ longitude?: number; // Decimal degrees
+ groundSpeed?: number; // Knots
+ trackAngle?: number; // Degrees
+ sections?: Segment[];
+}
+
+/**
+ * Altitude payload (barometric or geometric)
+ */
+export interface AltitudePayload {
+ type: 'altitude';
+ icao?: string; // ICAO address
+ altitude?: number; // Feet
+ altitudeType?: 'barometric' | 'geometric';
+ sections?: Segment[];
+}
+
+/**
+ * Emergency/Priority payload
+ */
+export interface EmergencyPayload {
+ type: 'emergency';
+ icao?: string; // ICAO address
+ emergencyState?: string; // Emergency declaration
+ sections?: Segment[];
+}
+
+/**
+ * Union type for all decoded ADSB payload types
+ */
+export type DecodedPayload =
+ | IdentificationPayload
+ | PositionPayload
+ | VelocityPayload
+ | SurfacePositionPayload
+ | AltitudePayload
+ | EmergencyPayload;
+
+/**
+ * ADSB Frame structure
+ */
+export interface Frame {
+ messageType: ADSBMessageType;
+ icao?: string; // 24-bit ICAO address
+ typeCode?: ADSBTypeCode; // Type code for DF17/18
+ payload: DecodedPayload;
+ raw: Uint8Array;
+ crc?: number; // Cyclic redundancy check
+ segments?: Segment[]; // Dissection segments
+}
diff --git a/ui/src/types/protocol/meshcore.types.ts b/ui/src/types/protocol/meshcore.types.ts
index 85c1f99..c25e2cb 100644
--- a/ui/src/types/protocol/meshcore.types.ts
+++ b/ui/src/types/protocol/meshcore.types.ts
@@ -1,4 +1,6 @@
+import { bytesToHex } from '@noble/hashes/utils.js';
import type { Segment } from './dissection.types';
+import { type PacketMeta, PacketInfo } from '../protocol.types';
export type NodeHash = string; // first byte of the hash
@@ -11,6 +13,13 @@ export const RouteType = {
export type RouteType = typeof RouteType[keyof typeof RouteType] | number;
+export const routeDisplayByValue: Record = {
+ [RouteType.TRANSPORT_FLOOD]: 'Flood (T)',
+ [RouteType.FLOOD]: 'Flood',
+ [RouteType.DIRECT]: 'Direct',
+ [RouteType.TRANSPORT_DIRECT]: 'Direct (T)',
+};
+
export const PayloadType = {
REQUEST: 0x00, // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
RESPONSE: 0x01, // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
@@ -30,6 +39,22 @@ export const PayloadType = {
export type PayloadTypeValue = typeof PayloadType[keyof typeof PayloadType];
export type PayloadType = PayloadTypeValue | number;
+export const payloadNameByValue: Record = {
+ [PayloadType.REQUEST]: 'Request',
+ [PayloadType.RESPONSE]: 'Response',
+ [PayloadType.TEXT]: 'Text Message',
+ [PayloadType.ACK]: 'Ack',
+ [PayloadType.ADVERT]: 'Advertisement',
+ [PayloadType.GROUP_TEXT]: 'Group Text Message',
+ [PayloadType.GROUP_DATA]: 'Group Data Message',
+ [PayloadType.ANON_REQ]: 'Anonymous Request',
+ [PayloadType.PATH]: 'Path Info',
+ [PayloadType.TRACE]: 'Trace Info',
+ [PayloadType.MULTIPART]: 'Multipart Packet',
+ [PayloadType.CONTROL]: 'Control Packet',
+ [PayloadType.RAW_CUSTOM]: 'Raw Custom Payload',
+};
+
export interface Packet {
version: number;
transportCodes?: Uint16Array;
@@ -40,19 +65,66 @@ export interface Packet {
sections?: Segment[];
}
-export abstract class BasePacket {
- protected version: number = 1;
- protected transportCodes?: Uint16Array;
- protected routeType: number = 0;
- protected payloadType: number = 0;
- protected pathLength: number = 0;
- protected path: Uint8Array = new Uint8Array();
- protected payload: Uint8Array = new Uint8Array();
+export abstract class BasePacket extends PacketInfo {
+ public header: number;
+ public transportCodes?: Uint16Array;
+ public pathLength: number;
+ public path: Uint8Array;
+ public payload: Uint8Array;
- abstract getPathHashSize(): number;
- abstract getPathHashCount(): number;
- abstract hash(): Uint8Array;
- abstract decode(): Payload;
+ constructor(
+ header: number,
+ transportCodes: Uint16Array | undefined,
+ pathLength: number,
+ path: Uint8Array,
+ payload: Uint8Array,
+ info: PacketMeta
+ ) {
+ super(info);
+ this.header = header;
+ this.transportCodes = transportCodes;
+ this.pathLength = pathLength;
+ this.path = path;
+ this.payload = payload;
+ }
+
+ public getRouteType(): RouteType {
+ return (this.header >> 0) & 0x03;
+ }
+
+ public getPayloadVersion(): number {
+ return (this.header >> 6) & 0x03;
+ }
+
+ public getPayloadType(): PayloadType {
+ return (this.header >> 2) & 0x0F;
+ }
+
+ public getPathHashSize(): number {
+ return (this.pathLength >> 6) + 1;
+ }
+
+ public getPathHashCount(): number {
+ return this.pathLength & 0x3F;
+ }
+
+ public getPathBytesLength(): number {
+ return this.getPathHashSize() * this.getPathHashCount();
+ }
+
+ public getPathHashes(): NodeHash[] {
+ const hashSize = this.getPathHashSize();
+ const hashCount = this.getPathHashCount();
+ const hashes: NodeHash[] = [];
+ for (let i = 0; i < hashCount; i++) {
+ const hashBytes = this.path.slice(i * hashSize, (i + 1) * hashSize);
+ hashes.push(bytesToHex(hashBytes));
+ }
+ return hashes;
+ }
+
+ abstract decode(emitSections: boolean): Payload;
+ abstract hash(): string;
}
export type Payload =
@@ -173,10 +245,9 @@ export interface SignedTextMessage extends DecryptedTextMessage {
senderPubkeyPrefix: Uint8Array; // First 4 bytes of sender pubkey (when txt_type = 0x02)
}
-export interface AckPayload extends EncryptedPayload {
+export interface AckPayload {
readonly payloadType: typeof PayloadType.ACK;
- dstHash: NodeHash;
- srcHash: NodeHash;
+ checksum: Uint8Array; // 4 bytes, LE
sections?: Segment[];
}
@@ -297,8 +368,9 @@ export interface DecryptedPath {
export interface TracePayload {
readonly payloadType: typeof PayloadType.TRACE;
- // Format not fully specified in docs - collecting SNI for each hop
- data: Uint8Array;
+ tag: number; // 4 bytes, LE - used to correlate trace requests and responses
+ authCode: Uint8Array; // 4 bytes - HMAC or similar to prevent spoofing
+ nodes: Uint8Array;
sections?: Segment[];
}
@@ -354,11 +426,6 @@ export interface RawCustomPayload {
sections?: Segment[];
}
-export interface Group {
- name: string;
- secret: BaseGroupSecret;
-}
-
export type PublicKeyValue = string | Uint8Array
export abstract class BasePublicKey {
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 17b89fe..9bdd17b 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -24,4 +24,13 @@ export default defineConfig({
},
},
},
+ build: {
+ rollupOptions: {
+ output: {
+ chunkFileNames: 'assets/[hash].js',
+ entryFileNames: 'assets/[hash].js',
+ assetFileNames: 'assets/[hash][extname]'
+ }
+ }
+ },
})