289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import React from 'react';
|
||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||
|
||
import { Card, Stack } from 'react-bootstrap';
|
||
|
||
import PacketDissectionViewer from '../../components/PacketDissectionViewer';
|
||
import type { Segment } from '../../types/protocol/dissection.types';
|
||
|
||
import {
|
||
payloadNameByValue,
|
||
routeDisplayByValue,
|
||
} from './MeshCoreData';
|
||
import { PayloadType, type AdvertPayload, type AnonReqPayload, type DecryptedGroupMessage, type GroupTextPayload, type Payload } from '../../types/protocol/meshcore.types';
|
||
import type { Packet } from '../../protocols/meshcore';
|
||
|
||
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 buildMeshCoreSegments = (packet: Packet): Segment[] => {
|
||
const { pathHashSize, pathHashCount } = packet;
|
||
const pathLength = pathHashSize * pathHashCount;
|
||
|
||
const segments: Segment[] = [
|
||
{
|
||
name: 'Header',
|
||
offset: 0,
|
||
byteCount: 1,
|
||
bitfields: [
|
||
{
|
||
offset: 6,
|
||
length: 2,
|
||
name: 'Version',
|
||
},
|
||
{
|
||
offset: 2,
|
||
length: 4,
|
||
name: `Payload Type (${payloadNameByValue[packet.payloadType] ?? 'Unknown'})`,
|
||
},
|
||
{
|
||
offset: 0,
|
||
length: 2,
|
||
name: `Route Type (${routeDisplayByValue[packet.routeType] ?? 'Unknown'})`,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Path Descriptor',
|
||
offset: 1,
|
||
byteCount: 1,
|
||
bitfields: [
|
||
{
|
||
offset: 6,
|
||
length: 2,
|
||
name: `Hash Size Selector (size=${pathHashSize})`,
|
||
},
|
||
{
|
||
offset: 0,
|
||
length: 6,
|
||
name: `Hash Count (count=${pathHashCount})`,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
if (pathLength > 0) {
|
||
segments.push({
|
||
name: 'Path Data',
|
||
offset: 2,
|
||
byteCount: pathLength,
|
||
attributes: [
|
||
{
|
||
byteWidth: pathLength,
|
||
type: 'bytes',
|
||
name: `Path Hashes (${pathHashCount} × ${pathHashSize} bytes)`,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
const payloadOffset = 2 + (packet.transportCodes ? packet.transportCodes.length * 2 : 0) + pathLength;
|
||
if (packet.payload.length > 0) {
|
||
segments.push({
|
||
name: 'Payload',
|
||
offset: payloadOffset,
|
||
byteCount: packet.payload.length,
|
||
attributes: [
|
||
{
|
||
byteWidth: packet.payload.length,
|
||
type: 'bytes',
|
||
name: `Payload Data (${packet.payload.length} bytes)`,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
if (packet.segments) {
|
||
segments.push(...packet.segments);
|
||
}
|
||
|
||
return segments;
|
||
};
|
||
|
||
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
||
if (value && typeof value === 'object') {
|
||
return value as Record<string, unknown>;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const PayloadDetails: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||
const payload = packet.decodedPayload as Payload | undefined;
|
||
const payloadObj = asRecord(payload);
|
||
|
||
if (typeof payload === 'undefined' || !payload || !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.payload)}</code>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (typeof payloadObj?.flags === 'number' && payloadObj?.data instanceof Uint8Array) {
|
||
return (
|
||
<Card body className="data-table-card">
|
||
<h6 className="mb-2">Control</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 (packet.payloadType === PayloadType.GROUP_TEXT && typeof packet.decrypted !== 'undefined') {
|
||
const payload = packet.decodedPayload as GroupTextPayload;
|
||
const decrypted = packet.decrypted as DecryptedGroupMessage;
|
||
return (
|
||
<Card body className="data-table-card">
|
||
<h6 className="mb-2">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
|
||
<HeaderFact label="Channel Hash" value={<><span className="meshcore-hash">{payload.channelHash}</span>{decrypted.group ? ` (${decrypted.group})` : ''}</>} />
|
||
<HeaderFact label="Timestamp" value={decrypted.timestamp.toLocaleString()} />
|
||
<HeaderFact label="Message" value={decrypted.message} />
|
||
</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">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
|
||
<HeaderFact label="Channel Hash" value={payloadObj.channelHash} />
|
||
<HeaderFact label="Cipher Text" value={payloadObj.cipherText.length + ' bytes'} />
|
||
<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">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
|
||
<HeaderFact label="Destination" value={<span className="meshcore-hash">{payloadObj.dstHash}</span>} />
|
||
<HeaderFact label="Source" value={<span className="meshcore-hash">{payloadObj.srcHash}</span>} />
|
||
<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">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Raw)</h6>
|
||
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
||
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (packet.payloadType === PayloadType.ADVERT) {
|
||
const advert = payload as AdvertPayload;
|
||
return (
|
||
<Card body className="data-table-card">
|
||
<h6 className="mb-2">Advert Payload</h6>
|
||
<HeaderFact label="Public Key" value={<code>{bytesToHex(advert.publicKey)}</code>} />
|
||
<HeaderFact label="Signature" value={advert.signature.length + ' Bytes'} />
|
||
{advert.timestamp && (
|
||
<HeaderFact label="Time" value={<code>{advert.timestamp.toISOString()}</code>} />
|
||
)}
|
||
{advert.appdata?.name && (
|
||
<HeaderFact label="Name" value={advert.appdata.name} />
|
||
)}
|
||
{advert.appdata?.latitude && (
|
||
<HeaderFact label="Latitude" value={advert.appdata.latitude} />
|
||
)}
|
||
{advert.appdata?.longitude && (
|
||
<HeaderFact label="Longitude" value={advert.appdata.longitude} />
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (packet.payloadType === PayloadType.ANON_REQ) {
|
||
const anonReq = payload as AnonReqPayload;
|
||
return (
|
||
<Card body className="data-table-card">
|
||
<h6 className="mb-2">Anonymous Request</h6>
|
||
<HeaderFact label="Destination" value={anonReq.dstHash} />
|
||
<HeaderFact label="Public Key" value={<code>{bytesToHex(anonReq.publicKey)}</code>} />
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card body className="data-table-card">
|
||
<h6 className="mb-2">Payload</h6>
|
||
<code>{JSON.stringify(payloadObj)}</code>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
interface MeshCorePacketDetailsPaneProps {
|
||
packet: Packet | 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">{payloadNameByValue[packet.payloadType] ?? packet.payloadType}</h6>
|
||
</Stack>
|
||
<HeaderFact label="Time" value={packet.timestamp.toISOString()} />
|
||
<HeaderFact label="Hash" value={<code>{packet.hash}</code>} />
|
||
{packet.snr !== undefined && <HeaderFact label="SNR" value={`${packet.snr.toFixed(1)} dB`} />}
|
||
{packet.rssi !== undefined && <HeaderFact label="RSSI" value={`${packet.rssi} dBm`} />}
|
||
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
|
||
<HeaderFact label="Version" value={packet.version} />
|
||
<HeaderFact label="Payload Type" value={`${payloadNameByValue[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>
|
||
*/}
|
||
<PayloadDetails packet={packet} />
|
||
<Card body className="data-table-card">
|
||
<PacketDissectionViewer
|
||
rawPacket={packet.toBytes()}
|
||
segments={packet.segments || buildMeshCoreSegments(packet)}
|
||
title={`${payloadNameByValue[packet.payloadType] ?? packet.payloadType} Packet Dissection`}
|
||
/>
|
||
</Card>
|
||
<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;
|