More APRS enhancements
This commit is contained in:
424
ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx
Normal file
424
ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
|
||||
import { Badge, Card, Stack } from 'react-bootstrap';
|
||||
|
||||
import type { MeshCorePacketRecord } from './MeshCoreContext';
|
||||
import {
|
||||
payloadDisplayByValue,
|
||||
routeDisplayByValue,
|
||||
} from './MeshCoreData';
|
||||
|
||||
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||
<div className="meshcore-fact-row">
|
||||
<span className="meshcore-fact-label">{label}</span>
|
||||
<span className="meshcore-fact-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toBitString = (value: number): string => value.toString(2).padStart(8, '0');
|
||||
|
||||
const byteHex = (value: number | undefined): string => {
|
||||
if (typeof value !== 'number') {
|
||||
return '0x??';
|
||||
}
|
||||
return `0x${value.toString(16).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const bitSlice = (value: number, msb: number, lsb: number): number => {
|
||||
const width = msb - lsb + 1;
|
||||
const mask = (1 << width) - 1;
|
||||
return (value >> lsb) & mask;
|
||||
};
|
||||
|
||||
const toAscii = (value: number): string => {
|
||||
if (value >= 32 && value <= 126) {
|
||||
return String.fromCharCode(value);
|
||||
}
|
||||
return '.';
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
if (value && typeof value === 'object') {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface BitFieldSpec {
|
||||
msb: number;
|
||||
lsb: number;
|
||||
shortLabel: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const buildBitPointerLine = (msb: number, lsb: number): string => {
|
||||
const width = 15;
|
||||
const start = (7 - msb) * 2;
|
||||
const end = (7 - lsb) * 2;
|
||||
const chars = Array.from({ length: width }, () => ' ');
|
||||
|
||||
if (start === end) {
|
||||
chars[start] = '↑';
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
chars[start] = '└';
|
||||
for (let i = start + 1; i < end; i += 1) {
|
||||
chars[i] = '─';
|
||||
}
|
||||
chars[end] = '┘';
|
||||
return chars.join('');
|
||||
};
|
||||
|
||||
const renderBitPointerArt = (
|
||||
value: number,
|
||||
fields: BitFieldSpec[],
|
||||
mode: 'compact' | 'verbose'
|
||||
): string => {
|
||||
const header = 'bits: 7 6 5 4 3 2 1 0';
|
||||
const bitRow = `val : ${toBitString(value).split('').join(' ')}`;
|
||||
const pointers = fields.map((field) => {
|
||||
const name = mode === 'compact' ? field.shortLabel : field.label;
|
||||
return ` ${buildBitPointerLine(field.msb, field.lsb)} ${name} = ${field.value}`;
|
||||
});
|
||||
|
||||
if (mode === 'compact') {
|
||||
const legend = `key : ${fields.map((field) => field.shortLabel).join(', ')}`;
|
||||
return [header, bitRow, ...pointers, legend].join('\n');
|
||||
}
|
||||
|
||||
return [header, bitRow, ...pointers].join('\n');
|
||||
};
|
||||
|
||||
interface ByteDissectionRow {
|
||||
index: number;
|
||||
byte: number;
|
||||
zone: 'header' | 'path' | 'payload';
|
||||
}
|
||||
|
||||
const buildByteDissection = (packet: MeshCorePacketRecord): {
|
||||
rows: ByteDissectionRow[];
|
||||
pathHashSize: number;
|
||||
pathHashCount: number;
|
||||
pathBytesAvailable: number;
|
||||
payloadOffset: number;
|
||||
} => {
|
||||
const pathField = packet.raw.length > 1 ? packet.raw[1] : 0;
|
||||
const pathHashSize = bitSlice(pathField, 7, 6) + 1;
|
||||
const pathHashCount = bitSlice(pathField, 5, 0);
|
||||
const pathBytesExpected = pathHashCount === 0 || pathHashSize === 4 ? 0 : pathHashCount * pathHashSize;
|
||||
const pathBytesAvailable = Math.min(pathBytesExpected, Math.max(packet.raw.length - 2, 0));
|
||||
const payloadOffset = 2 + pathBytesAvailable;
|
||||
|
||||
const rows: ByteDissectionRow[] = Array.from(packet.raw).map((byte, index) => {
|
||||
if (index <= 1) {
|
||||
return {
|
||||
index,
|
||||
byte,
|
||||
zone: 'header',
|
||||
};
|
||||
}
|
||||
|
||||
if (index < payloadOffset) {
|
||||
return {
|
||||
index,
|
||||
byte,
|
||||
zone: 'path',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
byte,
|
||||
zone: 'payload',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
pathHashSize,
|
||||
pathHashCount,
|
||||
pathBytesAvailable,
|
||||
payloadOffset,
|
||||
};
|
||||
};
|
||||
|
||||
const WireDissector: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
|
||||
const [bitArtMode, setBitArtMode] = useState<'compact' | 'verbose'>('compact');
|
||||
|
||||
const { rows, pathHashSize, pathHashCount, pathBytesAvailable, payloadOffset } = useMemo(
|
||||
() => buildByteDissection(packet),
|
||||
[packet]
|
||||
);
|
||||
|
||||
const headerByte = packet.raw[0] ?? 0;
|
||||
const pathByte = packet.raw[1] ?? 0;
|
||||
const pathBytes = packet.raw.slice(2, payloadOffset);
|
||||
const payloadBytes = packet.raw.slice(payloadOffset);
|
||||
|
||||
const hexdumpRows = useMemo(() => {
|
||||
const chunks: ByteDissectionRow[][] = [];
|
||||
for (let i = 0; i < rows.length; i += 16) {
|
||||
chunks.push(rows.slice(i, i + 16));
|
||||
}
|
||||
return chunks.map((chunk, rowIndex) => ({
|
||||
offset: rowIndex * 16,
|
||||
cells: [...chunk, ...Array<ByteDissectionRow | null>(Math.max(16 - chunk.length, 0)).fill(null)],
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<Stack direction="horizontal" className="justify-content-between align-items-center mb-2">
|
||||
<h6 className="mb-0">Packet Bytes (Wire View)</h6>
|
||||
<button
|
||||
type="button"
|
||||
className="meshcore-bitart-toggle"
|
||||
onClick={() => setBitArtMode((prev) => (prev === 'compact' ? 'verbose' : 'compact'))}
|
||||
title="Toggle compact/verbose bit pointer annotations"
|
||||
>
|
||||
Bit Art: {bitArtMode === 'compact' ? 'Compact' : 'Verbose'}
|
||||
</button>
|
||||
</Stack>
|
||||
<div className="meshcore-wire-subtitle">Protocol tree + hex dump, similar to a packet analyzer output</div>
|
||||
|
||||
<div className="meshcore-ws-layout mt-3">
|
||||
<div className="meshcore-ws-panel">
|
||||
<div className="meshcore-ws-panel-title">Packet Details</div>
|
||||
<ul className="meshcore-ws-tree">
|
||||
<li>
|
||||
<span className="meshcore-ws-node">Frame 1: {packet.raw.length} bytes on wire ({packet.raw.length * 8} bits)</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="meshcore-ws-node">MeshCore Header</span>
|
||||
<ul>
|
||||
<li>Byte[0] = <code>{byteHex(headerByte)}</code> = <code>{toBitString(headerByte)}</code></li>
|
||||
<li>b7..b6: Version = <strong>{bitSlice(headerByte, 7, 6)}</strong></li>
|
||||
<li>b5..b2: Payload Type = <strong>{bitSlice(headerByte, 5, 2)}</strong> ({payloadDisplayByValue[packet.payloadType] ?? 'Unknown'})</li>
|
||||
<li>b1..b0: Route Type = <strong>{bitSlice(headerByte, 1, 0)}</strong> ({routeDisplayByValue[packet.routeType] ?? 'Unknown'})</li>
|
||||
<li>
|
||||
<pre className="meshcore-bit-art">
|
||||
{renderBitPointerArt(headerByte, [
|
||||
{
|
||||
msb: 7,
|
||||
lsb: 6,
|
||||
shortLabel: 'ver',
|
||||
label: 'version (b7..b6)',
|
||||
value: bitSlice(headerByte, 7, 6),
|
||||
},
|
||||
{
|
||||
msb: 5,
|
||||
lsb: 2,
|
||||
shortLabel: 'ptype',
|
||||
label: 'payload_type (b5..b2)',
|
||||
value: bitSlice(headerByte, 5, 2),
|
||||
},
|
||||
{
|
||||
msb: 1,
|
||||
lsb: 0,
|
||||
shortLabel: 'route',
|
||||
label: 'route_type (b1..b0)',
|
||||
value: bitSlice(headerByte, 1, 0),
|
||||
},
|
||||
], bitArtMode)}
|
||||
</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span className="meshcore-ws-node">Path Descriptor</span>
|
||||
<ul>
|
||||
<li>Byte[1] = <code>{byteHex(pathByte)}</code> = <code>{toBitString(pathByte)}</code></li>
|
||||
<li>b7..b6: Hash size = ({bitSlice(pathByte, 7, 6)} + 1) = <strong>{pathHashSize}</strong></li>
|
||||
<li>b5..b0: Hash count = <strong>{pathHashCount}</strong></li>
|
||||
<li>Path bytes in frame = <strong>{pathBytesAvailable}</strong></li>
|
||||
<li>Path data = <code>{pathBytes.length > 0 ? bytesToHex(pathBytes) : 'none'}</code></li>
|
||||
<li>
|
||||
<pre className="meshcore-bit-art">
|
||||
{renderBitPointerArt(pathByte, [
|
||||
{
|
||||
msb: 7,
|
||||
lsb: 6,
|
||||
shortLabel: 'hsel',
|
||||
label: 'hash_size_selector (b7..b6)',
|
||||
value: bitSlice(pathByte, 7, 6),
|
||||
},
|
||||
{
|
||||
msb: 5,
|
||||
lsb: 0,
|
||||
shortLabel: 'hcnt',
|
||||
label: 'hash_count (b5..b0)',
|
||||
value: bitSlice(pathByte, 5, 0),
|
||||
},
|
||||
], bitArtMode)}
|
||||
</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span className="meshcore-ws-node">Payload</span>
|
||||
<ul>
|
||||
<li>Payload offset = <code>{payloadOffset}</code></li>
|
||||
<li>Payload length = <strong>{payloadBytes.length}</strong> bytes</li>
|
||||
<li>Payload bytes = <code>{payloadBytes.length > 0 ? bytesToHex(payloadBytes) : 'none'}</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="meshcore-ws-panel">
|
||||
<div className="meshcore-ws-panel-title">Hex Dump</div>
|
||||
<div className="meshcore-ws-legend mb-2">
|
||||
<span><i className="meshcore-ws-chip meshcore-ws-zone-header" />Header</span>
|
||||
<span><i className="meshcore-ws-chip meshcore-ws-zone-path" />Path</span>
|
||||
<span><i className="meshcore-ws-chip meshcore-ws-zone-payload" />Payload</span>
|
||||
</div>
|
||||
<div className="meshcore-ws-hexdump">
|
||||
{hexdumpRows.map((row) => (
|
||||
<div key={row.offset} className="meshcore-ws-hexdump-row">
|
||||
<span className="meshcore-ws-offset">{row.offset.toString(16).padStart(4, '0')}</span>
|
||||
<span className="meshcore-ws-bytes">
|
||||
{row.cells.map((cell, index) => (
|
||||
<span key={`${row.offset}-${index}`} className={`meshcore-ws-byte ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
|
||||
{cell ? cell.byte.toString(16).padStart(2, '0') : ' '}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="meshcore-ws-ascii">
|
||||
{row.cells.map((cell, index) => (
|
||||
<span key={`${row.offset}-ascii-${index}`} className={`meshcore-ws-char ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
|
||||
{cell ? toAscii(cell.byte) : ' '}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
|
||||
const payload = packet.decodedPayload;
|
||||
const payloadObj = asRecord(payload);
|
||||
|
||||
if (!payloadObj) {
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Payload</h6>
|
||||
<div>Unable to decode payload; showing raw bytes only.</div>
|
||||
<code>{bytesToHex(packet.raw)}</code>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof payloadObj.flags === 'number' && payloadObj.data instanceof Uint8Array) {
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">CONTROL Payload</h6>
|
||||
<HeaderFact label="Flags" value={`0x${payloadObj.flags.toString(16)}`} />
|
||||
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
||||
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof payloadObj.channelHash === 'string'
|
||||
&& payloadObj.cipherText instanceof Uint8Array
|
||||
&& payloadObj.cipherMAC instanceof Uint8Array
|
||||
) {
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">GROUP Payload</h6>
|
||||
<HeaderFact label="Channel Hash" value={payloadObj.channelHash} />
|
||||
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
|
||||
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof payloadObj.dstHash === 'string'
|
||||
&& typeof payloadObj.srcHash === 'string'
|
||||
&& payloadObj.cipherText instanceof Uint8Array
|
||||
&& payloadObj.cipherMAC instanceof Uint8Array
|
||||
) {
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Encrypted Payload</h6>
|
||||
<HeaderFact label="Destination" value={payloadObj.dstHash} />
|
||||
<HeaderFact label="Source" value={payloadObj.srcHash} />
|
||||
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
|
||||
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (payloadObj.data instanceof Uint8Array) {
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Raw Payload</h6>
|
||||
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
||||
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Payload</h6>
|
||||
<code>{JSON.stringify(payloadObj)}</code>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface MeshCorePacketDetailsPaneProps {
|
||||
packet: MeshCorePacketRecord | null;
|
||||
streamReady: boolean;
|
||||
}
|
||||
|
||||
const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ packet, streamReady }) => {
|
||||
if (!packet) {
|
||||
return (
|
||||
<Card body className="data-table-card h-100">
|
||||
<h6>Select a packet</h6>
|
||||
<div>Click any hash in the table to inspect MeshCore header and payload details.</div>
|
||||
<div className="mt-2 text-secondary">Stream prepared: {streamReady ? 'yes' : 'no'}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={2} className="h-100 meshcore-detail-stack">
|
||||
<Card body className="data-table-card">
|
||||
<Stack direction="horizontal" gap={2} className="mb-2">
|
||||
<h6 className="mb-0">Packet Header</h6>
|
||||
<Badge bg="primary">{payloadDisplayByValue[packet.payloadType] ?? packet.payloadType}</Badge>
|
||||
</Stack>
|
||||
<HeaderFact label="Time" value={packet.timestamp.toISOString()} />
|
||||
<HeaderFact label="Hash" value={<code>{packet.hash}</code>} />
|
||||
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
|
||||
<HeaderFact label="Version" value={packet.version} />
|
||||
<HeaderFact label="Payload Type" value={`${payloadDisplayByValue[packet.payloadType] ?? 'Unknown'} (${packet.payloadType})`} />
|
||||
<HeaderFact label="Route Type" value={routeDisplayByValue[packet.routeType] ?? packet.routeType} />
|
||||
<HeaderFact label="Raw Length" value={`${packet.raw.length} bytes`} />
|
||||
<HeaderFact label="Path" value={<code>{bytesToHex(packet.path)}</code>} />
|
||||
<HeaderFact label="Raw Packet" value={<code>{bytesToHex(packet.raw)}</code>} />
|
||||
</Card>
|
||||
<WireDissector packet={packet} />
|
||||
<PayloadDetails packet={packet} />
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Stream Preparation</h6>
|
||||
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
|
||||
<div className="text-secondary">Ready: {streamReady ? 'yes' : 'no'}</div>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeshCorePacketDetailsPane;
|
||||
Reference in New Issue
Block a user