Add packet feed clickable packet inspection. Closes #75 again.

This commit is contained in:
Jack Kingsman
2026-03-19 09:43:57 -07:00
parent 41a297c944
commit b85d451e26
10 changed files with 1320 additions and 240 deletions

View File

@@ -185,6 +185,7 @@ export function ConversationPane({
packets={rawPackets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
}

View File

@@ -0,0 +1,594 @@
import { useMemo, useState } from 'react';
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
import { cn } from '@/lib/utils';
import {
createDecoderOptions,
inspectRawPacketWithOptions,
type PacketByteField,
} from '../utils/rawPacketInspector';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
interface RawPacketDetailModalProps {
packet: RawPacket | null;
channels: Channel[];
onClose: () => void;
}
interface FieldPaletteEntry {
box: string;
boxActive: string;
hex: string;
hexActive: string;
}
interface GroupTextResolutionCandidate {
key: string;
name: string;
hash: string;
}
const FIELD_PALETTE: FieldPaletteEntry[] = [
{
box: 'border-sky-500/30 bg-sky-500/10',
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
hex: 'border-sky-500/40 bg-sky-500/20',
hexActive: 'border-sky-600 bg-sky-500/40',
},
{
box: 'border-emerald-500/30 bg-emerald-500/10',
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
hex: 'border-emerald-500/40 bg-emerald-500/20',
hexActive: 'border-emerald-600 bg-emerald-500/40',
},
{
box: 'border-amber-500/30 bg-amber-500/10',
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
hex: 'border-amber-500/40 bg-amber-500/20',
hexActive: 'border-amber-600 bg-amber-500/40',
},
{
box: 'border-rose-500/30 bg-rose-500/10',
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
hex: 'border-rose-500/40 bg-rose-500/20',
hexActive: 'border-rose-600 bg-rose-500/40',
},
{
box: 'border-violet-500/30 bg-violet-500/10',
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
hex: 'border-violet-500/40 bg-violet-500/20',
hexActive: 'border-violet-600 bg-violet-500/40',
},
{
box: 'border-cyan-500/30 bg-cyan-500/10',
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
hex: 'border-cyan-500/40 bg-cyan-500/20',
hexActive: 'border-cyan-600 bg-cyan-500/40',
},
{
box: 'border-lime-500/30 bg-lime-500/10',
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
hex: 'border-lime-500/40 bg-lime-500/20',
hexActive: 'border-lime-600 bg-lime-500/40',
},
{
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
hex: 'border-fuchsia-500/40 bg-fuchsia-500/20',
hexActive: 'border-fuchsia-600 bg-fuchsia-500/40',
},
];
function formatTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatSignal(packet: RawPacket): string {
const parts: string[] = [];
if (packet.rssi !== null) {
parts.push(`${packet.rssi} dBm RSSI`);
}
if (packet.snr !== null) {
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
}
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
}
function formatByteRange(field: PacketByteField): string {
if (field.absoluteStartByte === field.absoluteEndByte) {
return `Byte ${field.absoluteStartByte}`;
}
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
}
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
if (hopCount === 0) {
return 'No path hops';
}
if (!hashSize) {
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
}
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
}
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
return channels.map((channel) => ({
key: channel.key,
name: channel.name,
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
}));
}
function resolveGroupTextRoomName(
payload: {
channelHash?: string;
cipherMac?: string;
ciphertext?: string;
decrypted?: { message?: string };
},
candidates: GroupTextResolutionCandidate[]
): string | null {
if (!payload.channelHash) {
return null;
}
const hashMatches = candidates.filter(
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
);
if (hashMatches.length === 1) {
return hashMatches[0].name;
}
if (
hashMatches.length <= 1 ||
!payload.cipherMac ||
!payload.ciphertext ||
!payload.decrypted?.message
) {
return null;
}
const decryptMatches = hashMatches.filter(
(candidate) =>
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
.success
);
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
}
function packetShowsDecryptedState(
packet: RawPacket,
inspection: ReturnType<typeof inspectRawPacketWithOptions>
): boolean {
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
}
function getPacketContext(
packet: RawPacket,
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
groupTextCandidates: GroupTextResolutionCandidate[]
) {
const fallbackSender = packet.decrypted_info?.sender ?? null;
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
if (!inspection.decoded?.payload.decoded) {
if (!fallbackSender && !fallbackRoom) {
return null;
}
return {
title: fallbackRoom ? 'Room' : 'Context',
primary: fallbackRoom ?? 'Sender metadata available',
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
};
}
if (inspection.decoded.payloadType === PayloadType.GroupText) {
const payload = inspection.decoded.payload.decoded as {
channelHash?: string;
cipherMac?: string;
ciphertext?: string;
decrypted?: { sender?: string; message?: string };
};
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
return {
title: roomName ? 'Room' : 'Channel',
primary:
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
secondary: payload.decrypted?.sender
? `Sender: ${payload.decrypted.sender}`
: fallbackSender
? `Sender: ${fallbackSender}`
: null,
};
}
if (fallbackSender) {
return {
title: 'Context',
primary: fallbackSender,
secondary: null,
};
}
return null;
}
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
return [
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
...inspection.payloadFields,
];
}
function buildFieldColorMap(fields: PacketByteField[]) {
return new Map(
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
);
}
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
const owners = new Array<string | null>(totalBytes).fill(null);
for (const field of fields) {
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
if (index >= 0 && index < owners.length) {
owners[index] = field.id;
}
}
}
return owners;
}
function CompactMetaCard({
label,
primary,
secondary,
}: {
label: string;
primary: string;
secondary?: string | null;
}) {
return (
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
{secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
) : null}
</div>
);
}
function FullPacketHex({
packetHex,
fields,
colorMap,
hoveredFieldId,
onHoverField,
}: {
packetHex: string;
fields: PacketByteField[];
colorMap: Map<string, FieldPaletteEntry>;
hoveredFieldId: string | null;
onHoverField: (fieldId: string | null) => void;
}) {
const normalized = packetHex.toUpperCase();
const bytes = normalized.match(/.{1,2}/g) ?? [];
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
return (
<div className="rounded-lg border border-border/70 bg-muted/20 p-1.5 sm:p-2">
<div className="flex flex-wrap gap-1 font-mono text-[15px]">
{bytes.map((byte, byteIndex) => {
const fieldId = byteOwners[byteIndex];
const palette = fieldId ? colorMap.get(fieldId) : null;
const active = fieldId !== null && hoveredFieldId === fieldId;
return (
<span
key={byteIndex}
onMouseEnter={() => onHoverField(fieldId)}
onMouseLeave={() => onHoverField(null)}
className={cn(
'rounded border px-1.5 py-1 leading-none transition-colors',
palette
? active
? palette.hexActive
: palette.hex
: 'border-border/70 bg-background/70 text-foreground'
)}
>
{byte}
</span>
);
})}
</div>
</div>
);
}
function FieldBox({
field,
palette,
active,
onHoverField,
}: {
field: PacketByteField;
palette: FieldPaletteEntry;
active: boolean;
onHoverField: (fieldId: string | null) => void;
}) {
return (
<div
onMouseEnter={() => onHoverField(field.id)}
onMouseLeave={() => onHoverField(null)}
className={cn(
'rounded-lg border p-2.5 transition-colors',
active ? palette.boxActive : palette.box
)}
>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
<div className="min-w-0">
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
</div>
<div className="w-full break-all font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right">
{field.value.toUpperCase()}
</div>
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
{field.description}
</div>
{field.decryptedMessage ? (
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
</div>
<PlaintextContent text={field.decryptedMessage} />
</div>
) : null}
{field.headerBreakdown ? (
<div className="mt-2 space-y-1.5">
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
{field.headerBreakdown.fullBinary}
</div>
{field.headerBreakdown.fields.map((part) => (
<div
key={`${field.id}-${part.bits}-${part.field}`}
className="rounded border border-border/50 bg-background/40 p-2"
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-medium leading-tight text-foreground">
{part.field}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
</div>
<div className="text-right">
<div className="font-mono text-sm text-foreground">{part.binary}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
</div>
</div>
</div>
))}
</div>
) : null}
</div>
);
}
function PlaintextContent({ text }: { text: string }) {
const lines = text.split('\n');
return (
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
{lines.map((line, index) => {
const separatorIndex = line.indexOf(': ');
if (separatorIndex === -1) {
return (
<div key={`${line}-${index}`} className="font-mono">
{line}
</div>
);
}
const label = line.slice(0, separatorIndex + 1);
const value = line.slice(separatorIndex + 2);
return (
<div key={`${line}-${index}`}>
<span>{label} </span>
<span className="font-mono">{value}</span>
</div>
);
})}
</div>
);
}
function FieldSection({
title,
fields,
colorMap,
hoveredFieldId,
onHoverField,
}: {
title: string;
fields: PacketByteField[];
colorMap: Map<string, FieldPaletteEntry>;
hoveredFieldId: string | null;
onHoverField: (fieldId: string | null) => void;
}) {
return (
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
{fields.length === 0 ? (
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
) : (
<div className="grid gap-2">
{fields.map((field) => (
<FieldBox
key={field.id}
field={field}
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
active={hoveredFieldId === field.id}
onHoverField={onHoverField}
/>
))}
</div>
)}
</section>
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels),
[channels]
);
const inspection = useMemo(
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
[decoderOptions, packet]
);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const packetDisplayFields = useMemo(
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
[inspection]
);
const fullPacketFields = useMemo(
() => (inspection ? buildDisplayFields(inspection) : []),
[inspection]
);
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
const packetContext = useMemo(
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
[groupTextCandidates, inspection, packet]
);
const packetIsDecrypted = useMemo(
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
[inspection, packet]
);
if (!packet || !inspection) {
return null;
}
return (
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle>Packet Details</DialogTitle>
<DialogDescription className="sr-only">
Detailed byte and field breakdown for the selected raw packet.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { RawPacketList } from './RawPacketList';
import type { Contact, RawPacket } from '../types';
import { RawPacketDetailModal } from './RawPacketDetailModal';
import type { Channel, Contact, RawPacket } from '../types';
import {
RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot,
@@ -19,6 +20,7 @@ interface RawPacketFeedViewProps {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
contacts: Contact[];
channels: Channel[];
}
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
@@ -312,6 +314,7 @@ export function RawPacketFeedView({
packets,
rawPacketStatsSession,
contacts,
channels,
}: RawPacketFeedViewProps) {
const [statsOpen, setStatsOpen] = useState(() =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
@@ -320,6 +323,7 @@ export function RawPacketFeedView({
);
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
useEffect(() => {
const interval = window.setInterval(() => {
@@ -376,7 +380,7 @@ export function RawPacketFeedView({
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
<RawPacketList packets={packets} />
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
</div>
<aside
@@ -493,6 +497,12 @@ export function RawPacketFeedView({
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Hop Byte Width"
items={stats.hopByteWidthProfile}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Signal Distribution"
items={stats.rssiBuckets}
@@ -527,6 +537,12 @@ export function RawPacketFeedView({
) : null}
</aside>
</div>
<RawPacketDetailModal
packet={selectedPacket}
channels={channels}
onClose={() => setSelectedPacket(null)}
/>
</>
);
}

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
import type { Channel, RawPacket } from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector';
import { cn } from '@/lib/utils';
interface RawPacketListProps {
packets: RawPacket[];
channels?: Channel[];
onPacketClick?: (packet: RawPacket) => void;
}
function formatTime(timestamp: number): string {
@@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string {
return parts.join(' | ');
}
// Decrypted info from the packet (validated by backend)
interface DecryptedInfo {
channel_name: string | null;
sender: string | null;
}
// Decode a packet and generate a human-readable summary
// Uses backend's decrypted_info when available (validated), falls back to decoder
function decodePacketSummary(
hexData: string,
decryptedInfo: DecryptedInfo | null
): {
summary: string;
routeType: string;
details?: string;
} {
try {
const decoded = MeshCoreDecoder.decode(hexData);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
// Build path string if available
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
// Generate summary based on payload type
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
} | null;
// Use backend's validated decrypted_info when available
if (decryptedInfo?.channel_name) {
if (decryptedInfo.sender) {
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
} else {
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
}
} else if (payload?.channelHash) {
// Fallback to showing channel hash when not decrypted
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack: {
summary = `ACK${pathStr}`;
break;
}
case PayloadType.Request: {
summary = `Request${pathStr}`;
break;
}
case PayloadType.Response: {
summary = `Response${pathStr}`;
break;
}
case PayloadType.Trace: {
summary = `Trace${pathStr}`;
break;
}
case PayloadType.Path: {
summary = `Path${pathStr}`;
break;
}
default:
summary = `${payloadTypeName}${pathStr}`;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
// Get route type badge color
function getRouteTypeColor(routeType: string): string {
switch (routeType) {
@@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string {
}
}
export function RawPacketList({ packets }: RawPacketListProps) {
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
// Decode all packets (memoized to avoid re-decoding on every render)
const decodedPackets = useMemo(() => {
return packets.map((packet) => ({
packet,
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
decoded: decodePacketSummary(packet, decoderOptions),
}));
}, [packets]);
}, [decoderOptions, packets]);
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = useMemo(
@@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) {
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
ref={listRef}
>
{sortedPackets.map(({ packet, decoded }) => (
<div
key={getRawPacketObservationKey(packet)}
className="py-2 px-3 bg-card rounded-md border border-border/50"
>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{sortedPackets.map(({ packet, decoded }) => {
const cardContent = (
<>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
)}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)}
</div>
)}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)}
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()}
</div>
)}
</>
);
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()}
const className = cn(
'rounded-md border border-border/50 bg-card px-3 py-2 text-left',
onPacketClick &&
'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
);
if (onPacketClick) {
return (
<button
key={getRawPacketObservationKey(packet)}
type="button"
onClick={() => onPacketClick(packet)}
className={className}
>
{cardContent}
</button>
);
}
return (
<div key={getRawPacketObservationKey(packet)} className={className}>
{cardContent}
</div>
</div>
))}
);
})}
</div>
);
}

View File

@@ -3,7 +3,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { RawPacketFeedView } from '../components/RawPacketFeedView';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import type { Contact, RawPacket } from '../types';
import type { Channel, Contact, RawPacket } from '../types';
const GROUP_TEXT_PACKET_HEX =
'1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818';
const TEST_CHANNEL: Channel = {
key: '7ABA109EDCF304A84433CB71D0F3AB73',
name: '#six77',
is_hashtag: true,
on_radio: false,
last_read_at: null,
};
const COLLIDING_TEST_CHANNEL: Channel = {
...TEST_CHANNEL,
name: '#collision',
};
function createSession(
overrides: Partial<RawPacketStatsSessionState> = {}
@@ -78,15 +94,34 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
};
}
function renderView({
packets = [],
contacts = [],
channels = [],
rawPacketStatsSession = createSession(),
}: {
packets?: RawPacket[];
contacts?: Contact[];
channels?: Channel[];
rawPacketStatsSession?: RawPacketStatsSessionState;
} = {}) {
return render(
<RawPacketFeedView
packets={packets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
}
describe('RawPacketFeedView', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('opens a stats drawer with window controls and grouped summaries', () => {
render(
<RawPacketFeedView packets={[]} rawPacketStatsSession={createSession()} contacts={[]} />
);
renderView();
expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument();
expect(screen.queryByText('Packet Types')).not.toBeInTheDocument();
@@ -95,6 +130,7 @@ describe('RawPacketFeedView', () => {
expect(screen.getByLabelText('Stats window')).toBeInTheDocument();
expect(screen.getByText('Packet Types')).toBeInTheDocument();
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument();
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
});
@@ -114,11 +150,10 @@ describe('RawPacketFeedView', () => {
}))
);
render(
<RawPacketFeedView packets={[]} rawPacketStatsSession={createSession()} contacts={[]} />
);
renderView();
expect(screen.getByText('Packet Types')).toBeInTheDocument();
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument();
});
@@ -161,13 +196,11 @@ describe('RawPacketFeedView', () => {
],
});
const { rerender } = render(
<RawPacketFeedView
packets={initialPackets}
rawPacketStatsSession={initialSession}
contacts={[]}
/>
);
const { rerender } = renderView({
packets: initialPackets,
rawPacketStatsSession: initialSession,
contacts: [],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } });
@@ -179,6 +212,7 @@ describe('RawPacketFeedView', () => {
packets={nextPackets}
rawPacketStatsSession={initialSession}
contacts={[]}
channels={[]}
/>
);
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
@@ -195,7 +229,12 @@ describe('RawPacketFeedView', () => {
],
};
rerender(
<RawPacketFeedView packets={nextPackets} rawPacketStatsSession={nextSession} contacts={[]} />
<RawPacketFeedView
packets={nextPackets}
rawPacketStatsSession={nextSession}
contacts={[]}
channels={[]}
/>
);
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
@@ -203,30 +242,27 @@ describe('RawPacketFeedView', () => {
});
it('resolves neighbor labels from matching contacts when identity is available', () => {
render(
<RawPacketFeedView
packets={[]}
rawPacketStatsSession={createSession({
totalObservedPackets: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'AA11BB22CC33',
sourceLabel: 'AA11BB22CC33',
pathTokenCount: 1,
pathSignature: '01',
},
],
})}
contacts={[createContact()]}
/>
);
renderView({
rawPacketStatsSession: createSession({
totalObservedPackets: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'AA11BB22CC33',
sourceLabel: 'AA11BB22CC33',
pathTokenCount: 1,
pathSignature: '01',
},
],
}),
contacts: [createContact()],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
@@ -234,33 +270,86 @@ describe('RawPacketFeedView', () => {
});
it('marks unresolved neighbor identities explicitly', () => {
render(
<RawPacketFeedView
packets={[]}
rawPacketStatsSession={createSession({
totalObservedPackets: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'DEADBEEF1234',
sourceLabel: 'DEADBEEF1234',
pathTokenCount: 1,
pathSignature: '01',
},
],
})}
contacts={[]}
/>
);
renderView({
rawPacketStatsSession: createSession({
totalObservedPackets: 1,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'Advert',
routeType: 'Flood',
decrypted: false,
rssi: -70,
snr: 6,
sourceKey: 'DEADBEEF1234',
sourceLabel: 'DEADBEEF1234',
pathTokenCount: 1,
pathSignature: '01',
},
],
}),
contacts: [],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
});
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
renderView({
packets: [
{
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: GROUP_TEXT_PACKET_HEX,
decrypted: false,
payload_type: 'GroupText',
rssi: -72,
snr: 5.5,
decrypted_info: null,
},
],
channels: [TEST_CHANNEL],
});
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
expect(screen.getByText('Packet Details')).toBeInTheDocument();
expect(screen.getByText('Payload fields')).toBeInTheDocument();
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
expect(screen.getByText('#six77')).toBeInTheDocument();
expect(screen.getByText(/bytes · decrypted/i)).toBeInTheDocument();
expect(screen.getAllByText(/sender: flightless/i).length).toBeGreaterThan(0);
expect(
screen.getByText(/hello there; this hashtag room is essentially public/i)
).toBeInTheDocument();
});
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
renderView({
packets: [
{
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: GROUP_TEXT_PACKET_HEX,
decrypted: false,
payload_type: 'GroupText',
rssi: -72,
snr: 5.5,
decrypted_info: null,
},
],
channels: [TEST_CHANNEL, COLLIDING_TEST_CHANNEL],
});
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
expect(screen.getByText(/channel hash e6/i)).toBeInTheDocument();
expect(screen.queryByText('#six77')).not.toBeInTheDocument();
expect(screen.queryByText('#collision')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { PayloadType } from '@michaelhart/meshcore-decoder';
import { describeCiphertextStructure, formatHexByHop } from '../utils/rawPacketInspector';
describe('rawPacketInspector helpers', () => {
it('formats path hex as hop-delimited groups', () => {
expect(formatHexByHop('A1B2C3D4E5F6', 2)).toBe('A1B2 → C3D4 → E5F6');
expect(formatHexByHop('AABBCC', 1)).toBe('AA → BB → CC');
});
it('leaves non-hop-aligned hex unchanged', () => {
expect(formatHexByHop('A1B2C3', 2)).toBe('A1B2C3');
expect(formatHexByHop('A1B2', null)).toBe('A1B2');
});
it('describes undecryptable ciphertext with multiline bullets', () => {
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
'\n• Timestamp (4 bytes)'
);
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
'\n• Flags (1 byte)'
);
expect(describeCiphertextStructure(PayloadType.TextMessage, 12, 'fallback')).toContain(
'\n• Message (remaining bytes)'
);
});
});

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RawPacketList } from '../components/RawPacketList';
import type { RawPacket } from '../types';
@@ -23,5 +23,17 @@ describe('RawPacketList', () => {
render(<RawPacketList packets={[createPacket()]} />);
expect(screen.getByText('TF')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('makes packet cards clickable only when an inspector handler is provided', () => {
const packet = createPacket({ id: 9, observation_id: 22 });
const onPacketClick = vi.fn();
render(<RawPacketList packets={[packet]} onPacketClick={onPacketClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onPacketClick).toHaveBeenCalledWith(packet);
});
});

View File

@@ -25,6 +25,7 @@ function createSession(
sourceLabel: 'AA11',
pathTokenCount: 2,
pathSignature: '01>02',
hopByteWidth: 1,
},
{
observationKey: 'obs-2',
@@ -38,6 +39,7 @@ function createSession(
sourceLabel: 'BB22',
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
},
{
observationKey: 'obs-3',
@@ -51,6 +53,7 @@ function createSession(
sourceLabel: 'AA11',
pathTokenCount: 1,
pathSignature: '02',
hopByteWidth: 2,
},
{
observationKey: 'obs-4',
@@ -64,6 +67,7 @@ function createSession(
sourceLabel: null,
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
},
],
...overrides,
@@ -88,6 +92,13 @@ describe('buildRawPacketStatsSnapshot', () => {
expect.objectContaining({ label: 'Control', count: 0 }),
])
);
expect(stats.hopByteWidthProfile).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: 'No path', count: 2 }),
expect.objectContaining({ label: '1 byte / hop', count: 1 }),
expect.objectContaining({ label: '2 bytes / hop', count: 1 }),
])
);
expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 });
expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 });
expect(stats.windowFullyCovered).toBe(true);

View File

@@ -0,0 +1,388 @@
import {
MeshCoreDecoder,
PayloadType,
Utils,
type DecodedPacket,
type DecryptionOptions,
type HeaderBreakdown,
type PacketStructure,
} from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
export interface RawPacketSummary {
summary: string;
routeType: string;
details?: string;
}
export interface PacketByteField {
id: string;
scope: 'packet' | 'payload';
name: string;
description: string;
value: string;
decryptedMessage?: string;
startByte: number;
endByte: number;
absoluteStartByte: number;
absoluteEndByte: number;
headerBreakdown?: HeaderBreakdown;
}
export interface RawPacketInspection {
decoded: DecodedPacket | null;
structure: PacketStructure | null;
routeTypeName: string;
payloadTypeName: string;
payloadVersionName: string;
pathTokens: string[];
summary: RawPacketSummary;
validationErrors: string[];
packetFields: PacketByteField[];
payloadFields: PacketByteField[];
}
export function formatHexByHop(hex: string, hashSize: number | null | undefined): string {
const normalized = hex.trim().toUpperCase();
if (!normalized || !hashSize || hashSize < 1) {
return normalized;
}
const charsPerHop = hashSize * 2;
if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) {
return normalized;
}
const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g'));
return hops && hops.length > 1 ? hops.join(' → ') : normalized;
}
export function describeCiphertextStructure(
payloadType: PayloadType,
byteLength: number,
fallbackDescription: string
): string {
switch (payloadType) {
case PayloadType.GroupText:
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Flags (1 byte) - room-message flags byte
• Message (remaining bytes) - UTF-8 room message text`;
case PayloadType.TextMessage:
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Message (remaining bytes) - UTF-8 direct message text`;
case PayloadType.Response:
return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Tag (4 bytes) - request/response correlation tag
• Content (remaining bytes) - response body`;
default:
return fallbackDescription;
}
}
function getPathTokens(decoded: DecodedPacket): string[] {
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
return tracePayload?.pathHashes || decoded.path || [];
}
function formatUnixTimestamp(timestamp: number): string {
return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`;
}
function createPacketField(
scope: 'packet' | 'payload',
id: string,
field: {
name: string;
description: string;
value: string;
decryptedMessage?: string;
startByte: number;
endByte: number;
headerBreakdown?: HeaderBreakdown;
},
absoluteOffset: number
): PacketByteField {
return {
id,
scope,
name: field.name,
description: field.description,
value: field.value,
decryptedMessage: field.decryptedMessage,
startByte: field.startByte,
endByte: field.endByte,
absoluteStartByte: absoluteOffset + field.startByte,
absoluteEndByte: absoluteOffset + field.endByte,
headerBreakdown: field.headerBreakdown,
};
}
export function createDecoderOptions(
channels: Channel[] | null | undefined
): DecryptionOptions | undefined {
const channelSecrets =
channels
?.map((channel) => channel.key?.trim())
.filter((key): key is string => Boolean(key && key.length > 0)) ?? [];
if (channelSecrets.length === 0) {
return undefined;
}
return {
keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }),
attemptDecryption: true,
};
}
function safeValidate(hexData: string): string[] {
try {
const validation = MeshCoreDecoder.validate(hexData);
return validation.errors ?? [];
} catch (error) {
return [error instanceof Error ? error.message : 'Packet validation failed'];
}
}
export function decodePacketSummary(
packet: RawPacket,
decoderOptions?: DecryptionOptions
): RawPacketSummary {
try {
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
const pathTokens = getPathTokens(decoded);
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : '';
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
decrypted?: { sender?: string; message?: string };
} | null;
if (packet.decrypted_info?.channel_name) {
if (packet.decrypted_info.sender) {
summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`;
} else {
summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`;
}
} else if (payload?.decrypted?.sender) {
summary = `GT from ${payload.decrypted.sender}${pathStr}`;
} else if (payload?.decrypted?.message) {
summary = `GT decrypted${pathStr}`;
} else if (payload?.channelHash) {
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack:
summary = `ACK${pathStr}`;
break;
case PayloadType.Request:
summary = `Request${pathStr}`;
break;
case PayloadType.Response:
summary = `Response${pathStr}`;
break;
case PayloadType.Trace:
summary = `Trace${pathStr}`;
break;
case PayloadType.Path:
summary = `Path${pathStr}`;
break;
default:
summary = `${payloadTypeName}${pathStr}`;
break;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
export function inspectRawPacket(packet: RawPacket): RawPacketInspection {
return inspectRawPacketWithOptions(packet);
}
export function inspectRawPacketWithOptions(
packet: RawPacket,
decoderOptions?: DecryptionOptions
): RawPacketInspection {
const summary = decodePacketSummary(packet, decoderOptions);
const validationErrors = safeValidate(packet.data);
let decoded: DecodedPacket | null = null;
let structure: PacketStructure | null = null;
try {
decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
} catch {
decoded = null;
}
try {
structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions);
} catch {
structure = null;
}
const routeTypeName = decoded?.isValid
? Utils.getRouteTypeName(decoded.routeType)
: summary.routeType;
const payloadTypeName = decoded?.isValid
? Utils.getPayloadTypeName(decoded.payloadType)
: packet.payload_type;
const payloadVersionName = decoded?.isValid
? Utils.getPayloadVersionName(decoded.payloadVersion)
: 'Unknown';
const pathTokens = decoded?.isValid ? getPathTokens(decoded) : [];
const packetFields =
structure?.segments
.map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0))
.map((field) => {
if (field.name !== 'Path Data') {
return field;
}
const hashSize =
decoded?.pathHashSize ??
(decoded?.pathLength && decoded.pathLength > 0
? Math.max(1, field.value.length / 2 / decoded.pathLength)
: null);
return {
...field,
value: formatHexByHop(field.value, hashSize),
};
}) ?? [];
const payloadFields =
structure == null
? []
: (structure.payload.segments.length > 0
? structure.payload.segments
: structure.payload.hex.length > 0
? [
{
name: 'Payload Bytes',
description:
'Field-level payload breakdown is not available for this packet type.',
startByte: 0,
endByte: Math.max(0, structure.payload.hex.length / 2 - 1),
value: structure.payload.hex,
},
]
: []
).map((segment, index) =>
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
);
const enrichedPayloadFields =
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
? payloadFields.map((field) => {
if (field.name !== 'Ciphertext') {
return field;
}
const payload = decoded.payload.decoded as {
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
};
if (!payload.decrypted?.message) {
return field;
}
const detailLines = [
payload.decrypted.timestamp != null
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
: null,
payload.decrypted.flags != null
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
: null,
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
`Message: ${payload.decrypted.message}`,
].filter((line): line is string => line !== null);
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
decryptedMessage: detailLines.join('\n'),
};
})
: payloadFields.map((field) => {
if (!decoded?.isValid || field.name !== 'Ciphertext') {
return field;
}
return {
...field,
description: describeCiphertextStructure(
decoded.payloadType,
field.endByte - field.startByte + 1,
field.description
),
};
});
return {
decoded,
structure,
routeTypeName,
payloadTypeName,
payloadVersionName,
pathTokens,
summary,
validationErrors:
validationErrors.length > 0
? validationErrors
: (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])),
packetFields,
payloadFields: enrichedPayloadFields,
};
}

View File

@@ -51,6 +51,7 @@ export interface RawPacketStatsObservation {
sourceLabel: string | null;
pathTokenCount: number;
pathSignature: string | null;
hopByteWidth?: number | null;
}
export interface RawPacketStatsSessionState {
@@ -97,6 +98,7 @@ export interface RawPacketStatsSnapshot {
routeBreakdown: RankedPacketStat[];
topPacketTypes: RankedPacketStat[];
hopProfile: RankedPacketStat[];
hopByteWidthProfile: RankedPacketStat[];
strongestNeighbors: NeighborStat[];
mostActiveNeighbors: NeighborStat[];
newestNeighbors: NeighborStat[];
@@ -228,6 +230,7 @@ export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObs
sourceLabel: sourceInfo.sourceLabel,
pathTokenCount: pathTokens.length,
pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null,
hopByteWidth: pathTokens.length > 0 ? (decoded.pathHashSize ?? 1) : null,
};
} catch {
return {
@@ -242,10 +245,26 @@ export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObs
sourceLabel: null,
pathTokenCount: 0,
pathSignature: null,
hopByteWidth: null,
};
}
}
function inferHopByteWidth(packet: RawPacketStatsObservation): number | null {
if (packet.pathTokenCount <= 0) {
return null;
}
if (packet.hopByteWidth && packet.hopByteWidth > 0) {
return packet.hopByteWidth;
}
const firstToken = packet.pathSignature?.split('>')[0] ?? null;
if (!firstToken || firstToken.length % 2 !== 0) {
return null;
}
const inferred = firstToken.length / 2;
return inferred >= 1 && inferred <= 3 ? inferred : null;
}
function share(count: number, total: number): number {
if (total <= 0) return 0;
return count / total;
@@ -306,6 +325,13 @@ export function buildRawPacketStatsSnapshot(
['1 hop', 0],
['2+ hops', 0],
]);
const hopByteWidthCounts = new Map<string, number>([
['No path', 0],
['1 byte / hop', 0],
['2 bytes / hop', 0],
['3 bytes / hop', 0],
['Unknown width', 0],
]);
const neighborMap = new Map<string, NeighborStat>();
const rssiValues: number[] = [];
const rssiBucketCounts = new Map<string, number>([
@@ -328,6 +354,19 @@ export function buildRawPacketStatsSnapshot(
hopCounts.set('2+ hops', (hopCounts.get('2+ hops') ?? 0) + 1);
}
const hopByteWidth = inferHopByteWidth(packet);
if (packet.pathTokenCount <= 0) {
hopByteWidthCounts.set('No path', (hopByteWidthCounts.get('No path') ?? 0) + 1);
} else if (hopByteWidth === 1) {
hopByteWidthCounts.set('1 byte / hop', (hopByteWidthCounts.get('1 byte / hop') ?? 0) + 1);
} else if (hopByteWidth === 2) {
hopByteWidthCounts.set('2 bytes / hop', (hopByteWidthCounts.get('2 bytes / hop') ?? 0) + 1);
} else if (hopByteWidth === 3) {
hopByteWidthCounts.set('3 bytes / hop', (hopByteWidthCounts.get('3 bytes / hop') ?? 0) + 1);
} else {
hopByteWidthCounts.set('Unknown width', (hopByteWidthCounts.get('Unknown width') ?? 0) + 1);
}
if (packet.sourceKey && packet.sourceLabel) {
const existing = neighborMap.get(packet.sourceKey);
if (!existing) {
@@ -448,6 +487,7 @@ export function buildRawPacketStatsSnapshot(
routeBreakdown: rankedBreakdown(routeCounts, packetCount),
topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5),
hopProfile: rankedBreakdown(hopCounts, packetCount),
hopByteWidthProfile: rankedBreakdown(hopByteWidthCounts, packetCount),
strongestNeighbors,
mostActiveNeighbors,
newestNeighbors,