More APRS enhancements
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 36s
Test and build / Build receiver (push) Failing after 36s

This commit is contained in:
2026-03-05 22:24:09 +01:00
parent 7a8d7b0275
commit e83df1c143
115 changed files with 3987 additions and 956 deletions

View 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;