Added SNR and refactored types
This commit is contained in:
@@ -62,6 +62,11 @@ Run `npm run build` and run `pre-commit run --files changed files...`
|
||||
|
||||
Never add secrets to code.
|
||||
|
||||
## Modifying code
|
||||
|
||||
Prefer the patching strategy over running shell commands where possible.
|
||||
Prevent using temporary files and shell commands where possible.
|
||||
|
||||
## Addressing
|
||||
|
||||
Don't call me "the user", refer to me as "the developer".
|
||||
|
||||
327
ui/src/components/PacketDissectionViewer.scss
Normal file
327
ui/src/components/PacketDissectionViewer.scss
Normal file
@@ -0,0 +1,327 @@
|
||||
// Packet Dissection Viewer Styles
|
||||
|
||||
.packet-dissection-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.packet-dissection-title {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--app-text);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.packet-dissection-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--app-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Hexdump Panel
|
||||
.packet-dissection-hexdump-panel {
|
||||
border: 1px solid rgba(173, 205, 255, 0.25);
|
||||
background: rgba(8, 24, 56, 0.45);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.packet-dissection-panel-header {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--app-text-muted);
|
||||
background: rgba(13, 36, 82, 0.45);
|
||||
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
// Legend
|
||||
.packet-dissection-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--app-text-muted);
|
||||
border-bottom: 1px solid rgba(173, 205, 255, 0.15);
|
||||
}
|
||||
|
||||
.packet-dissection-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.packet-dissection-legend-chip {
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(173, 205, 255, 0.35);
|
||||
}
|
||||
|
||||
// Hexdump
|
||||
.packet-dissection-hexdump {
|
||||
padding: 0.45rem 0.55rem 0.65rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--app-text);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.packet-dissection-hexdump-inline {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.3rem 0.35rem 0.4rem;
|
||||
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(13, 36, 82, 0.25);
|
||||
}
|
||||
|
||||
.packet-dissection-hexdump-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(220px, 1fr) 90px;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.packet-dissection-offset {
|
||||
color: #82aeff;
|
||||
}
|
||||
|
||||
.packet-dissection-bytes,
|
||||
.packet-dissection-ascii {
|
||||
display: inline-flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.packet-dissection-byte {
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
min-width: 1.35rem;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--app-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.1);
|
||||
filter: brightness(1.3);
|
||||
box-shadow: 0 0 4px rgba(90, 146, 255, 0.5);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.packet-dissection-byte-hovered {
|
||||
filter: brightness(1.4);
|
||||
box-shadow: 0 0 6px rgba(90, 146, 255, 0.7);
|
||||
}
|
||||
|
||||
.packet-dissection-byte-active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.packet-dissection-byte-static {
|
||||
cursor: default;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
transform: none;
|
||||
filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.packet-dissection-byte-empty {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.packet-dissection-char {
|
||||
display: inline-block;
|
||||
min-width: 0.65rem;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.packet-dissection-char-empty {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
// Segments
|
||||
.packet-dissection-segments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.packet-dissection-segment {
|
||||
border: 1px solid rgba(173, 205, 255, 0.25);
|
||||
border-left-width: 4px;
|
||||
background: rgba(8, 24, 56, 0.45);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover,
|
||||
&.packet-dissection-segment-hovered {
|
||||
background: rgba(13, 36, 82, 0.6);
|
||||
border-color: rgba(173, 205, 255, 0.45);
|
||||
box-shadow: 0 0 8px rgba(90, 146, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.packet-dissection-segment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
|
||||
}
|
||||
|
||||
.packet-dissection-segment-name {
|
||||
font-weight: 600;
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.packet-dissection-segment-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.packet-dissection-segment-content {
|
||||
padding: 0.65rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.packet-dissection-segment-raw {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
|
||||
code {
|
||||
color: #b8d1ff;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.packet-dissection-label {
|
||||
color: var(--app-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.packet-dissection-list {
|
||||
margin: 0.35rem 0 0 0;
|
||||
padding-left: 1.25rem;
|
||||
list-style: none;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
|
||||
li {
|
||||
margin: 0.3rem 0;
|
||||
color: var(--app-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.packet-dissection-field-name {
|
||||
color: #d7e7ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.packet-dissection-field-type {
|
||||
color: var(--app-text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.packet-dissection-field-bits {
|
||||
color: #82aeff;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.packet-dissection-field-value {
|
||||
color: #b8d1ff;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
// Nested Children
|
||||
.packet-dissection-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.packet-dissection-child {
|
||||
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(13, 36, 82, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.packet-dissection-child-header {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: rgba(13, 36, 82, 0.45);
|
||||
border-bottom: 1px solid rgba(173, 205, 255, 0.15);
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #d7e7ff;
|
||||
}
|
||||
|
||||
.packet-dissection-child-content {
|
||||
padding: 0.5rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Bitfields & Attributes sections
|
||||
.packet-dissection-bitfields,
|
||||
.packet-dissection-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.packet-dissection-bitfield-byte {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.packet-dissection-bit-art {
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: rgba(13, 36, 82, 0.35);
|
||||
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
color: #b8d1ff;
|
||||
font-family: monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
563
ui/src/components/PacketDissectionViewer.tsx
Normal file
563
ui/src/components/PacketDissectionViewer.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import type { Segment } from '../types/protocol/dissection.types';
|
||||
import './PacketDissectionViewer.scss';
|
||||
|
||||
interface PacketDissectionViewerProps {
|
||||
rawPacket: string | Uint8Array | undefined;
|
||||
segments: Segment[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw packet to Uint8Array for consistent handling
|
||||
*/
|
||||
const toBytes = (raw: string | Uint8Array | undefined): Uint8Array => {
|
||||
if (!raw) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(raw);
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a color palette for segment visualization
|
||||
* Returns an array of CSS color values
|
||||
*/
|
||||
const generateColorPalette = (count: number): string[] => {
|
||||
const colors = [
|
||||
'rgba(90, 146, 255, 0.2)', // Blue
|
||||
'rgba(168, 85, 247, 0.2)', // Purple
|
||||
'rgba(34, 197, 94, 0.2)', // Green
|
||||
'rgba(251, 146, 60, 0.2)', // Orange
|
||||
'rgba(236, 72, 153, 0.2)', // Pink
|
||||
'rgba(14, 165, 233, 0.2)', // Cyan
|
||||
'rgba(234, 179, 8, 0.2)', // Yellow
|
||||
'rgba(239, 68, 68, 0.2)', // Red
|
||||
'rgba(139, 92, 246, 0.2)', // Violet
|
||||
'rgba(6, 182, 212, 0.2)', // Teal
|
||||
];
|
||||
|
||||
if (count <= colors.length) {
|
||||
return colors.slice(0, count);
|
||||
}
|
||||
|
||||
// Generate additional colors if needed
|
||||
const result = [...colors];
|
||||
for (let i = colors.length; i < count; i += 1) {
|
||||
const hue = (i * 360) / count;
|
||||
result.push(`hsla(${hue}, 70%, 60%, 0.2)`);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a number as a hex string with appropriate padding
|
||||
*/
|
||||
const toHexValue = (value: number, byteWidth: number): string => {
|
||||
const hexDigits = byteWidth * 2;
|
||||
return `0x${value.toString(16).padStart(hexDigits, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte as ASCII character (or dot for non-printable)
|
||||
*/
|
||||
const toAscii = (value: number): string => {
|
||||
if (value >= 32 && value <= 126) {
|
||||
return String.fromCharCode(value);
|
||||
}
|
||||
return '.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract value from bytes based on type
|
||||
*/
|
||||
const extractValue = (bytes: Uint8Array, offset: number, type: string, byteWidth: number): string | number => {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'uint8':
|
||||
return view.getUint8(offset);
|
||||
case 'int8':
|
||||
return view.getInt8(offset);
|
||||
case 'uint16le':
|
||||
return view.getUint16(offset, true);
|
||||
case 'uint16be':
|
||||
return view.getUint16(offset, false);
|
||||
case 'int16le':
|
||||
return view.getInt16(offset, true);
|
||||
case 'int16be':
|
||||
return view.getInt16(offset, false);
|
||||
case 'uint32le':
|
||||
return view.getUint32(offset, true);
|
||||
case 'uint32be':
|
||||
return view.getUint32(offset, false);
|
||||
case 'int32le':
|
||||
return view.getInt32(offset, true);
|
||||
case 'int32be':
|
||||
return view.getInt32(offset, false);
|
||||
case 'float32le':
|
||||
return view.getFloat32(offset, true);
|
||||
case 'float32be':
|
||||
return view.getFloat32(offset, false);
|
||||
case 'float64le':
|
||||
return view.getFloat64(offset, true);
|
||||
case 'float64be':
|
||||
return view.getFloat64(offset, false);
|
||||
case 'string':
|
||||
case 'char': {
|
||||
const charBytes = bytes.slice(offset, offset + byteWidth);
|
||||
return new TextDecoder().decode(charBytes);
|
||||
}
|
||||
case 'bytes':
|
||||
return bytesToHex(bytes.slice(offset, offset + byteWidth));
|
||||
default:
|
||||
return bytesToHex(bytes.slice(offset, offset + byteWidth));
|
||||
}
|
||||
} catch {
|
||||
return '(error)';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract bits from a byte
|
||||
*/
|
||||
const extractBits = (byte: number, offset: number, length: number): number => {
|
||||
const mask = (1 << length) - 1;
|
||||
return (byte >> offset) & mask;
|
||||
};
|
||||
|
||||
const formatAttributeValue = (type: string, value: string | number, byteWidth: number): string => {
|
||||
if (type === 'string' || type === 'char') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return toHexValue(value, byteWidth);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert byte to binary string
|
||||
*/
|
||||
const toBinaryString = (value: number): string => value.toString(2).padStart(8, '0');
|
||||
|
||||
/**
|
||||
* Build ASCII art pointer line for bitfield visualization
|
||||
*/
|
||||
const buildBitPointerLine = (offset: number, length: number): string => {
|
||||
const width = 15;
|
||||
const msb = offset + length - 1;
|
||||
const lsb = offset;
|
||||
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('');
|
||||
};
|
||||
|
||||
interface ByteCell {
|
||||
value: number;
|
||||
segmentIndex: number;
|
||||
}
|
||||
|
||||
interface HexdumpRow {
|
||||
offset: number;
|
||||
cells: (ByteCell | null)[];
|
||||
}
|
||||
|
||||
const PacketDissectionViewer: React.FC<PacketDissectionViewerProps> = ({
|
||||
rawPacket,
|
||||
segments,
|
||||
title = 'Packet Dissection',
|
||||
}) => {
|
||||
const bytes = useMemo(() => toBytes(rawPacket), [rawPacket]);
|
||||
const colors = useMemo(() => generateColorPalette(segments.length), [segments.length]);
|
||||
const sectionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const [hoveredSegment, setHoveredSegment] = useState<number | null>(null);
|
||||
|
||||
// Build byte-to-segment mapping
|
||||
const byteMapping = useMemo(() => {
|
||||
const mapping: number[] = new Array(bytes.length).fill(-1);
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const start = segment.offset;
|
||||
const end = segment.offset + segment.byteCount;
|
||||
for (let i = start; i < end && i < bytes.length; i += 1) {
|
||||
mapping[i] = index;
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}, [bytes.length, segments]);
|
||||
|
||||
// Build hexdump rows (8 bytes per row)
|
||||
const hexdumpRows = useMemo((): HexdumpRow[] => {
|
||||
const rows: HexdumpRow[] = [];
|
||||
for (let i = 0; i < bytes.length; i += 8) {
|
||||
const cells: (ByteCell | null)[] = [];
|
||||
for (let j = 0; j < 8; j += 1) {
|
||||
const index = i + j;
|
||||
if (index < bytes.length) {
|
||||
cells.push({
|
||||
value: bytes[index],
|
||||
segmentIndex: byteMapping[index],
|
||||
});
|
||||
} else {
|
||||
cells.push(null);
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
offset: i,
|
||||
cells,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [bytes, byteMapping]);
|
||||
|
||||
const offsetHexWidth = useMemo(() => {
|
||||
if (bytes.length === 0) {
|
||||
return 2;
|
||||
}
|
||||
const maxOffset = bytes.length - 1;
|
||||
return Math.max(2, maxOffset.toString(16).length);
|
||||
}, [bytes.length]);
|
||||
|
||||
const handleByteClick = (segmentIndex: number) => {
|
||||
if (segmentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = sectionRefs.current.get(segmentIndex);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
element.style.outline = '2px solid rgba(90, 146, 255, 0.6)';
|
||||
setTimeout(() => {
|
||||
element.style.outline = '';
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleByteHover = (segmentIndex: number | null) => {
|
||||
setHoveredSegment(segmentIndex);
|
||||
};
|
||||
|
||||
const renderInlineHexdump = (data: Uint8Array, baseOffset: number, color: string): React.ReactNode => {
|
||||
const rows: Array<{ offset: number; cells: Array<number | null> }> = [];
|
||||
|
||||
const alignedStart = Math.floor(baseOffset / 8) * 8;
|
||||
const endExclusive = baseOffset + data.length;
|
||||
|
||||
for (let rowOffset = alignedStart; rowOffset < endExclusive; rowOffset += 8) {
|
||||
const cells: Array<number | null> = [];
|
||||
for (let j = 0; j < 8; j += 1) {
|
||||
const absoluteIndex = rowOffset + j;
|
||||
if (absoluteIndex < baseOffset || absoluteIndex >= endExclusive) {
|
||||
cells.push(null);
|
||||
} else {
|
||||
cells.push(data[absoluteIndex - baseOffset]);
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
offset: rowOffset,
|
||||
cells,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="packet-dissection-hexdump packet-dissection-hexdump-inline">
|
||||
{rows.map((row) => (
|
||||
<div key={row.offset} className="packet-dissection-hexdump-row">
|
||||
<span className="packet-dissection-offset">
|
||||
{row.offset.toString(16).padStart(offsetHexWidth, '0')}
|
||||
</span>
|
||||
<span className="packet-dissection-bytes">
|
||||
{row.cells.map((cell, index) => (
|
||||
<span
|
||||
key={`inline-byte-${row.offset}-${index}`}
|
||||
className={`packet-dissection-byte packet-dissection-byte-static ${cell === null ? 'packet-dissection-byte-empty' : ''}`}
|
||||
style={cell !== null ? { backgroundColor: color } : undefined}
|
||||
>
|
||||
{cell !== null ? cell.toString(16).padStart(2, '0') : ' '}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="packet-dissection-ascii">
|
||||
{row.cells.map((cell, index) => (
|
||||
<span
|
||||
key={`inline-ascii-${row.offset}-${index}`}
|
||||
className={`packet-dissection-char ${cell === null ? 'packet-dissection-char-empty' : ''}`}
|
||||
style={cell !== null ? { backgroundColor: color } : undefined}
|
||||
>
|
||||
{cell !== null ? toAscii(cell) : ' '}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (bytes.length === 0) {
|
||||
return (
|
||||
<div className="packet-dissection-viewer">
|
||||
<div className="packet-dissection-empty">No packet data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="packet-dissection-viewer">
|
||||
<h6 className="packet-dissection-title">{title}</h6>
|
||||
|
||||
{/* Hexdump Section */}
|
||||
<div className="packet-dissection-hexdump-panel">
|
||||
<div className="packet-dissection-panel-header">Hex Dump</div>
|
||||
<div className="packet-dissection-legend">
|
||||
{segments.map((segment, index) => (
|
||||
<span key={segment.name} className="packet-dissection-legend-item">
|
||||
<i
|
||||
className="packet-dissection-legend-chip"
|
||||
style={{ backgroundColor: colors[index] }}
|
||||
/>
|
||||
{segment.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="packet-dissection-hexdump">
|
||||
{hexdumpRows.map((row) => (
|
||||
<div key={row.offset} className="packet-dissection-hexdump-row">
|
||||
<span className="packet-dissection-offset">
|
||||
{row.offset.toString(16).padStart(offsetHexWidth, '0')}
|
||||
</span>
|
||||
<span className="packet-dissection-bytes">
|
||||
{row.cells.map((cell, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`byte-${row.offset}-${index}`}
|
||||
className={`packet-dissection-byte ${
|
||||
cell && cell.segmentIndex >= 0 ? 'packet-dissection-byte-active' : 'packet-dissection-byte-empty'
|
||||
} ${
|
||||
cell && cell.segmentIndex === hoveredSegment ? 'packet-dissection-byte-hovered' : ''
|
||||
}`}
|
||||
style={
|
||||
cell && cell.segmentIndex >= 0
|
||||
? { backgroundColor: colors[cell.segmentIndex] }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => cell && handleByteClick(cell.segmentIndex)}
|
||||
onMouseEnter={() => cell && handleByteHover(cell.segmentIndex)}
|
||||
onMouseLeave={() => handleByteHover(null)}
|
||||
disabled={!cell || cell.segmentIndex < 0}
|
||||
>
|
||||
{cell ? cell.value.toString(16).padStart(2, '0') : ' '}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<span className="packet-dissection-ascii">
|
||||
{row.cells.map((cell, index) => (
|
||||
<span
|
||||
key={`ascii-${row.offset}-${index}`}
|
||||
className={`packet-dissection-char ${
|
||||
cell && cell.segmentIndex >= 0 ? 'packet-dissection-char-active' : 'packet-dissection-char-empty'
|
||||
}`}
|
||||
style={
|
||||
cell && cell.segmentIndex >= 0
|
||||
? { backgroundColor: colors[cell.segmentIndex] }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{cell ? toAscii(cell.value) : ' '}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segments Section */}
|
||||
<div className="packet-dissection-segments">
|
||||
{segments.map((segment, segmentIndex) => {
|
||||
const segmentBytes = bytes.slice(segment.offset, segment.offset + segment.byteCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={segment.name}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
sectionRefs.current.set(segmentIndex, el);
|
||||
} else {
|
||||
sectionRefs.current.delete(segmentIndex);
|
||||
}
|
||||
}}
|
||||
className={`packet-dissection-segment ${
|
||||
hoveredSegment === segmentIndex ? 'packet-dissection-segment-hovered' : ''
|
||||
}`}
|
||||
style={{ borderLeftColor: colors[segmentIndex] }}
|
||||
onMouseEnter={() => setHoveredSegment(segmentIndex)}
|
||||
onMouseLeave={() => setHoveredSegment(null)}
|
||||
>
|
||||
<div
|
||||
className="packet-dissection-segment-header"
|
||||
style={{ backgroundColor: colors[segmentIndex] }}
|
||||
>
|
||||
<span className="packet-dissection-segment-name">{segment.name}</span>
|
||||
<span className="packet-dissection-segment-info">
|
||||
Offset: {segment.offset} (0x{segment.offset.toString(16)}), Length: {segment.byteCount} bytes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="packet-dissection-segment-content">
|
||||
{/* Raw bytes / string */}
|
||||
{segment.stringOnly ? (
|
||||
<div className="packet-dissection-segment-raw">
|
||||
<span className="packet-dissection-label">String:</span>
|
||||
<code>{new TextDecoder().decode(segmentBytes)}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="packet-dissection-label">Bytes:</span>
|
||||
{renderInlineHexdump(segmentBytes, segment.offset, colors[segmentIndex])}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bitfields */}
|
||||
{segment.bitfields && segment.bitfields.length > 0 && (
|
||||
<div className="packet-dissection-bitfields">
|
||||
<div className="packet-dissection-label">Bitfields:</div>
|
||||
{/* Group bitfields by byte */}
|
||||
{(() => {
|
||||
const byteGroups = new Map<number, typeof segment.bitfields>();
|
||||
segment.bitfields.forEach((bf) => {
|
||||
const byteIdx = Math.floor(bf.offset / 8);
|
||||
if (!byteGroups.has(byteIdx)) {
|
||||
byteGroups.set(byteIdx, []);
|
||||
}
|
||||
byteGroups.get(byteIdx)!.push(bf);
|
||||
});
|
||||
|
||||
return Array.from(byteGroups.entries()).map(([byteIdx, bitfields]) => {
|
||||
const byte = segmentBytes[byteIdx] ?? 0;
|
||||
return (
|
||||
<div key={`byte-${byteIdx}`} className="packet-dissection-bitfield-byte">
|
||||
<pre className="packet-dissection-bit-art">
|
||||
{'7 6 5 4 3 2 1 0 (bit numbers)\n'}
|
||||
{`${toBinaryString(byte).split('').join(' ')} (bits)\n`}
|
||||
{bitfields.map((bf) => {
|
||||
const bitOffset = bf.offset % 8;
|
||||
const value = extractBits(byte, bitOffset, bf.length);
|
||||
const pointer = buildBitPointerLine(bitOffset, bf.length);
|
||||
return `${pointer} ${bf.name} = ${value}\n`;
|
||||
}).join('')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributes */}
|
||||
{segment.attributes && segment.attributes.length > 0 && (
|
||||
<div className="packet-dissection-attributes">
|
||||
<div className="packet-dissection-label">Attributes:</div>
|
||||
<ul className="packet-dissection-list">
|
||||
{segment.attributes.map((attr, attrIndex) => {
|
||||
const attrOffset = segment.attributes!
|
||||
.slice(0, attrIndex)
|
||||
.reduce((sum, a) => sum + a.byteWidth, 0);
|
||||
const value = extractValue(segmentBytes, attrOffset, attr.type, attr.byteWidth);
|
||||
const displayValue = formatAttributeValue(attr.type, value, attr.byteWidth);
|
||||
|
||||
return (
|
||||
<li key={`${attr.name}-${attrIndex}`}>
|
||||
<span className="packet-dissection-field-name">{attr.name}</span>
|
||||
<span className="packet-dissection-field-type">({attr.type})</span>
|
||||
<span className="packet-dissection-field-value">= {displayValue}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nested children */}
|
||||
{segment.children && segment.children.length > 0 && (
|
||||
<div className="packet-dissection-children">
|
||||
<div className="packet-dissection-label">Nested Segments:</div>
|
||||
{segment.children.map((child) => {
|
||||
const childBytes = bytes.slice(child.offset, child.offset + child.byteCount);
|
||||
|
||||
return (
|
||||
<div key={child.name} className="packet-dissection-child">
|
||||
<div className="packet-dissection-child-header">{child.name}</div>
|
||||
<div className="packet-dissection-child-content">
|
||||
{child.stringOnly ? (
|
||||
<div className="packet-dissection-segment-raw">
|
||||
<span className="packet-dissection-label">String:</span>
|
||||
<code>{new TextDecoder().decode(childBytes)}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="packet-dissection-label">Bytes:</span>
|
||||
{renderInlineHexdump(childBytes, child.offset, colors[segmentIndex])}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{child.attributes && child.attributes.length > 0 && (
|
||||
<ul className="packet-dissection-list">
|
||||
{child.attributes.map((attr, attrIndex) => {
|
||||
const attrOffset = child.attributes!
|
||||
.slice(0, attrIndex)
|
||||
.reduce((sum, a) => sum + a.byteWidth, 0);
|
||||
const value = extractValue(childBytes, attrOffset, attr.type, attr.byteWidth);
|
||||
const displayValue = formatAttributeValue(attr.type, value, attr.byteWidth);
|
||||
|
||||
return (
|
||||
<li key={`${attr.name}-${attrIndex}`}>
|
||||
<span className="packet-dissection-field-name">{attr.name}</span>
|
||||
<span className="packet-dissection-field-type">({attr.type})</span>
|
||||
<span className="packet-dissection-field-value">= {displayValue}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PacketDissectionViewer;
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Card } from 'react-bootstrap';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { Radio } from '../types/radio.types';
|
||||
import { getDeviceImageURL } from '../libs/deviceImageMapper';
|
||||
import { toModulationDisplayName, toProtocolDisplayName } from '../types/protocol.types';
|
||||
import './RadioCard.scss';
|
||||
|
||||
interface RadioCardProps {
|
||||
@@ -9,10 +11,17 @@ interface RadioCardProps {
|
||||
}
|
||||
|
||||
export const RadioCard: React.FC<RadioCardProps> = ({ radio }) => {
|
||||
const navigate = useNavigate();
|
||||
const deviceImageURL = getDeviceImageURL(radio.protocol, radio.manufacturer, radio.device);
|
||||
const modulationName = toModulationDisplayName(radio.modulation);
|
||||
const protocolName = toProtocolDisplayName(radio.protocol);
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/${radio.protocol}/packets?radios=${encodeURIComponent(radio.name)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="radio-card">
|
||||
<Card className="radio-card radio-card-clickable" onClick={handleClick}>
|
||||
<Card.Header className="radio-card-header">
|
||||
<div className="radio-card-image-container">
|
||||
<img
|
||||
@@ -38,11 +47,11 @@ export const RadioCard: React.FC<RadioCardProps> = ({ radio }) => {
|
||||
</div>
|
||||
<div className="radio-card-row">
|
||||
<span className="radio-card-label">Modulation:</span>
|
||||
<span className="radio-card-value">{radio.modulation}</span>
|
||||
<span className="radio-card-value">{modulationName}</span>
|
||||
</div>
|
||||
<div className="radio-card-row">
|
||||
<span className="radio-card-label">Protocol:</span>
|
||||
<span className="radio-card-value">{radio.protocol}</span>
|
||||
<span className="radio-card-value">{protocolName}</span>
|
||||
</div>
|
||||
{(radio.lora_sf !== undefined || radio.lora_cr !== undefined) && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.overview-container {
|
||||
padding: 24px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -8,6 +8,17 @@
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.overview-category {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.overview-category-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.overview-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
@@ -40,11 +51,29 @@
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.overview-radios-row {
|
||||
.overview-radios-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-radio-col {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
.overview-radios-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-radio-item {
|
||||
display: flex;
|
||||
|
||||
.radio-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.overview-radios-row {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,105 @@
|
||||
import type React from "react";
|
||||
import Layout from "../components/Layout";
|
||||
import RadioCard from "../components/RadioCard";
|
||||
import { useRadios } from "../contexts/RadiosContext";
|
||||
import type { NavLinkItem } from "../types/layout.types";
|
||||
import type { Radio } from "../types/radio.types";
|
||||
import './Overview.scss';
|
||||
|
||||
interface Props {
|
||||
navLinks?: NavLinkItem[]
|
||||
}
|
||||
|
||||
const MAX_RADIOS_PER_ROW = 6;
|
||||
|
||||
const chunkRadios = (radios: Radio[], chunkSize: number): Radio[][] => {
|
||||
const chunks: Radio[][] = [];
|
||||
for (let i = 0; i < radios.length; i += chunkSize) {
|
||||
chunks.push(radios.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const compareRadio = (left: Radio, right: Radio): number => {
|
||||
const protocolCompare = left.protocol.localeCompare(right.protocol, undefined, { sensitivity: 'base' });
|
||||
if (protocolCompare !== 0) {
|
||||
return protocolCompare;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
|
||||
};
|
||||
|
||||
export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
|
||||
const { radios, loading, error } = useRadios();
|
||||
const sortedOnlineRadios = radios
|
||||
.filter((radio) => radio.is_online)
|
||||
.sort(compareRadio);
|
||||
const sortedOfflineRadios = radios
|
||||
.filter((radio) => !radio.is_online)
|
||||
.sort(compareRadio);
|
||||
const totalRadiosCount = radios.length;
|
||||
const onlineRadiosCount = sortedOnlineRadios.length;
|
||||
const offlineRadiosCount = sortedOfflineRadios.length;
|
||||
const onlineRows = chunkRadios(sortedOnlineRadios, MAX_RADIOS_PER_ROW);
|
||||
const offlineRows = chunkRadios(sortedOfflineRadios, MAX_RADIOS_PER_ROW);
|
||||
|
||||
return (
|
||||
<Layout navLinks={navLinks}>
|
||||
Hi mom!
|
||||
<div className="overview-container">
|
||||
<section className="overview-section">
|
||||
<h1 className="overview-title">Radios ({totalRadiosCount})</h1>
|
||||
|
||||
{loading && <div className="overview-loading">Loading radios…</div>}
|
||||
|
||||
{!loading && error && <div className="overview-error">{error}</div>}
|
||||
|
||||
{!loading && !error && radios.length === 0 && (
|
||||
<div className="overview-empty">No radios found.</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && radios.length > 0 && (
|
||||
<>
|
||||
<div className="overview-category">
|
||||
<h2 className="overview-category-title">Online ({onlineRadiosCount})</h2>
|
||||
{onlineRows.length === 0 ? (
|
||||
<div className="overview-empty">No online radios.</div>
|
||||
) : (
|
||||
<div className="overview-radios-rows">
|
||||
{onlineRows.map((row, rowIndex) => (
|
||||
<div className="overview-radios-row" key={`overview-online-row-${rowIndex}`}>
|
||||
{row.map((radio) => (
|
||||
<div className="overview-radio-item" key={radio.id}>
|
||||
<RadioCard radio={radio} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overview-category">
|
||||
<h2 className="overview-category-title">Offline ({offlineRadiosCount})</h2>
|
||||
{offlineRows.length === 0 ? (
|
||||
<div className="overview-empty">No offline radios.</div>
|
||||
) : (
|
||||
<div className="overview-radios-rows">
|
||||
{offlineRows.map((row, rowIndex) => (
|
||||
<div className="overview-radios-row" key={`overview-offline-row-${rowIndex}`}>
|
||||
{row.map((radio) => (
|
||||
<div className="overview-radio-item" key={radio.id}>
|
||||
<RadioCard radio={radio} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface APRSPacketRecord {
|
||||
symbol?: string;
|
||||
radioName?: string;
|
||||
hasAPILocation?: boolean;
|
||||
snr?: number;
|
||||
rssi?: number;
|
||||
}
|
||||
|
||||
interface APRSDataContextValue {
|
||||
@@ -173,6 +175,8 @@ const buildRecord = ({
|
||||
longitude,
|
||||
symbol,
|
||||
preferProvidedLocation,
|
||||
snr,
|
||||
rssi,
|
||||
}: {
|
||||
raw: string;
|
||||
timestamp: Date;
|
||||
@@ -182,6 +186,8 @@ const buildRecord = ({
|
||||
longitude?: number;
|
||||
symbol?: string;
|
||||
preferProvidedLocation?: boolean;
|
||||
snr?: number;
|
||||
rssi?: number;
|
||||
}): APRSPacketRecord | null => {
|
||||
try {
|
||||
const frame = parseFrame(raw);
|
||||
@@ -207,6 +213,8 @@ const buildRecord = ({
|
||||
symbol: symbol ?? details.symbol,
|
||||
radioName,
|
||||
hasAPILocation: preferProvidedLocation && hasProvidedLocation,
|
||||
snr,
|
||||
rssi,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -228,6 +236,8 @@ const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null =>
|
||||
longitude: fromNullableNumber(packet.longitude),
|
||||
symbol: packet.symbol,
|
||||
preferProvidedLocation: true,
|
||||
snr: packet.snr,
|
||||
rssi: packet.rssi,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -236,6 +246,8 @@ const toRecordFromStream = (message: APRSMessage): APRSPacketRecord | null => {
|
||||
raw: message.raw,
|
||||
timestamp: message.receivedAt,
|
||||
radioName: message.radioName,
|
||||
snr: message.snr,
|
||||
rssi: message.rssi,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, Card, Stack, Table } from 'react-bootstrap';
|
||||
import { Card, Table } from 'react-bootstrap';
|
||||
import { MapContainer, TileLayer, Popup, useMap, CircleMarker, Marker } from 'react-leaflet';
|
||||
import { divIcon, type DivIcon } from 'leaflet';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import VerticalSplit from '../../components/VerticalSplit';
|
||||
import HorizontalSplit from '../../components/HorizontalSplit';
|
||||
import PacketDissectionViewer from '../../components/PacketDissectionViewer';
|
||||
import CountryFlag from '../../components/CountryFlag';
|
||||
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
||||
import StreamStatus from '../../components/StreamStatus';
|
||||
import { APRSSymbol } from '../../components/aprs';
|
||||
import { ClusteredMarkers } from '../../components/map';
|
||||
import type { ClusterableItem, Cluster } from '../../components/map';
|
||||
import type { Segment } from '../../types/protocol/dissection.types';
|
||||
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||
import { useAPRSData } from './APRSData';
|
||||
import type { APRSPacketRecord } from './APRSData';
|
||||
|
||||
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||
<div className="aprs-fact-row">
|
||||
<span className="aprs-fact-label">{label}</span>
|
||||
<span className="aprs-fact-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => (
|
||||
<span className={plain ? 'callsign callsign--plain' : 'callsign'}>
|
||||
{call}
|
||||
@@ -345,6 +340,112 @@ const APRSMapPane: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const shiftSegment = (segment: Segment, delta: number): Segment => ({
|
||||
...segment,
|
||||
offset: segment.offset + delta,
|
||||
children: segment.children?.map((child) => shiftSegment(child, delta)),
|
||||
});
|
||||
|
||||
const buildFallbackAPRSSegments = (packet: APRSPacketRecord): Segment[] => {
|
||||
const raw = packet.raw;
|
||||
const gtIndex = raw.indexOf('>');
|
||||
const colonIndex = raw.indexOf(':');
|
||||
|
||||
if (gtIndex < 0 || colonIndex < 0 || colonIndex <= gtIndex) {
|
||||
return [
|
||||
{
|
||||
name: 'raw_packet',
|
||||
offset: 0,
|
||||
byteCount: raw.length,
|
||||
attributes: [{ byteWidth: raw.length, type: 'string', name: 'packet' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const sections: Segment[] = [];
|
||||
const children: Segment[] = [];
|
||||
|
||||
const sourcePart = raw.substring(0, gtIndex);
|
||||
children.push({
|
||||
name: 'source',
|
||||
offset: 0,
|
||||
byteCount: sourcePart.length,
|
||||
attributes: [{ byteWidth: sourcePart.length, type: 'string', name: 'callsign' }],
|
||||
});
|
||||
|
||||
const headerPart = raw.substring(gtIndex + 1, colonIndex);
|
||||
const headerParts = headerPart.split(',');
|
||||
const destinationPart = headerParts[0] ?? '';
|
||||
const destinationOffset = gtIndex + 1;
|
||||
children.push({
|
||||
name: 'destination',
|
||||
offset: destinationOffset,
|
||||
byteCount: destinationPart.length,
|
||||
attributes: [{ byteWidth: destinationPart.length, type: 'string', name: 'callsign' }],
|
||||
});
|
||||
|
||||
let pathOffset = destinationOffset + destinationPart.length;
|
||||
for (let i = 1; i < headerParts.length; i += 1) {
|
||||
const repeater = headerParts[i] ?? '';
|
||||
pathOffset += 1;
|
||||
children.push({
|
||||
name: `repeater_${i - 1}`,
|
||||
offset: pathOffset,
|
||||
byteCount: repeater.length,
|
||||
attributes: [{ byteWidth: repeater.length, type: 'string', name: 'callsign' }],
|
||||
});
|
||||
pathOffset += repeater.length;
|
||||
}
|
||||
|
||||
sections.push({
|
||||
name: 'routing',
|
||||
offset: 0,
|
||||
byteCount: colonIndex,
|
||||
children,
|
||||
});
|
||||
|
||||
const payloadOffset = colonIndex + 1;
|
||||
if (payloadOffset < raw.length) {
|
||||
const payloadLength = raw.length - payloadOffset;
|
||||
sections.push({
|
||||
name: 'payload',
|
||||
offset: payloadOffset,
|
||||
byteCount: payloadLength,
|
||||
children: [
|
||||
{
|
||||
name: 'data_type',
|
||||
offset: payloadOffset,
|
||||
byteCount: 1,
|
||||
attributes: [{ byteWidth: 1, type: 'char', name: 'identifier' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const buildAPRSSegments = (packet: APRSPacketRecord): Segment[] => {
|
||||
const payloadStart = packet.raw.indexOf(':') + 1;
|
||||
|
||||
try {
|
||||
const decoded = packet.frame.decode(true);
|
||||
const sections = decoded.sections ?? [];
|
||||
if (sections.length === 0) {
|
||||
return buildFallbackAPRSSegments(packet);
|
||||
}
|
||||
|
||||
return sections.map((section) => {
|
||||
if (section.name === 'Routing' || section.name === 'Data Type') {
|
||||
return section;
|
||||
}
|
||||
return shiftSegment(section, payloadStart);
|
||||
});
|
||||
} catch {
|
||||
return buildFallbackAPRSSegments(packet);
|
||||
}
|
||||
};
|
||||
|
||||
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
|
||||
if (!packet) {
|
||||
return (
|
||||
@@ -356,50 +457,13 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={2} className="h-100 aprs-detail-stack">
|
||||
<Card body className="data-table-card">
|
||||
<Stack direction="horizontal" gap={2} className="mb-2">
|
||||
<h6 className="mb-0">Packet Details</h6>
|
||||
<Badge bg="primary">
|
||||
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} plain />
|
||||
</Badge>
|
||||
</Stack>
|
||||
<HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} />
|
||||
<HeaderFact
|
||||
label="Source"
|
||||
value={<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />}
|
||||
<Card body className="data-table-card h-100">
|
||||
<PacketDissectionViewer
|
||||
rawPacket={packet.raw}
|
||||
segments={buildAPRSSegments(packet)}
|
||||
title="APRS Packet Dissection"
|
||||
/>
|
||||
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
|
||||
<HeaderFact
|
||||
label="Destination"
|
||||
value={<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />}
|
||||
/>
|
||||
<HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
|
||||
</Card>
|
||||
|
||||
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Position Data</h6>
|
||||
{packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.toFixed(6)} />}
|
||||
{packet.longitude && <HeaderFact label="Longitude" value={packet.longitude.toFixed(6)} />}
|
||||
{packet.altitude && <HeaderFact label="Altitude" value={`${packet.altitude.toFixed(0)} m`} />}
|
||||
{packet.speed !== undefined && <HeaderFact label="Speed" value={`${packet.speed} kt`} />}
|
||||
{packet.course !== undefined && <HeaderFact label="Course" value={`${packet.course}°`} />}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{packet.comment && (
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Comment</h6>
|
||||
<div>{packet.comment}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Raw Data</h6>
|
||||
<code className="aprs-raw-code">{packet.raw}</code>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface MeshCorePacketRecord {
|
||||
decodedPayload?: unknown;
|
||||
payloadSummary: string;
|
||||
radioName?: string;
|
||||
snr?: number;
|
||||
rssi?: number;
|
||||
}
|
||||
|
||||
export interface MeshCoreGroupChatRecord {
|
||||
|
||||
@@ -12,10 +12,10 @@ import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
|
||||
import { Packet } from '../../protocols/meshcore';
|
||||
import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types';
|
||||
import { NodeType, PayloadType, RouteType } from '../../types/protocol/meshcore.types';
|
||||
import { MeshCoreStream } from '../../services/MeshCoreStream';
|
||||
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
|
||||
import type { Payload, AdvertPayload } from '../../protocols/meshcore.types';
|
||||
import type { Payload, AdvertPayload } from '../../types/protocol/meshcore.types';
|
||||
import API from '../../services/API';
|
||||
import MeshCoreServiceImpl from '../../services/MeshCoreService';
|
||||
import { base64ToBytes } from '../../util';
|
||||
@@ -37,17 +37,36 @@ export {
|
||||
type MeshCoreNodePoint,
|
||||
} from './MeshCoreContext';
|
||||
|
||||
export const payloadNameByValue = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => [value, name])
|
||||
) as Record<number, string>;
|
||||
export const payloadNameByValue: Record<number, string> = {
|
||||
[PayloadType.REQUEST]: 'Request',
|
||||
[PayloadType.RESPONSE]: 'Response',
|
||||
[PayloadType.TEXT]: 'Private Message',
|
||||
[PayloadType.ACK]: 'Acknowledgement',
|
||||
[PayloadType.ADVERT]: 'Advertisement',
|
||||
[PayloadType.GROUP_TEXT]: 'Group Message',
|
||||
[PayloadType.GROUP_DATA]: 'Group Data',
|
||||
[PayloadType.ANON_REQ]: 'Anonymous Request',
|
||||
[PayloadType.PATH]: 'Path',
|
||||
[PayloadType.TRACE]: 'Trace',
|
||||
[PayloadType.MULTIPART]: 'Multipart',
|
||||
[PayloadType.CONTROL]: 'Control',
|
||||
[PayloadType.RAW_CUSTOM]: 'Custom',
|
||||
};
|
||||
|
||||
export const nodeTypeNameByValue = Object.fromEntries(
|
||||
Object.entries(NodeType).map(([name, value]) => [value, name])
|
||||
) as Record<number, string>;
|
||||
export const nodeTypeNameByValue: Record<number, string> = {
|
||||
[NodeType.TYPE_UNKNOWN]: 'Unknown',
|
||||
[NodeType.TYPE_CHAT_NODE]: 'Chat Node',
|
||||
[NodeType.TYPE_REPEATER]: 'Repeater',
|
||||
[NodeType.TYPE_ROOM_SERVER]: 'Room Server',
|
||||
[NodeType.TYPE_SENSOR]: 'Sensor',
|
||||
};
|
||||
|
||||
export const routeTypeNameByValue = Object.fromEntries(
|
||||
Object.entries(RouteType).map(([name, value]) => [value, name])
|
||||
) as Record<number, string>;
|
||||
export const routeTypeNameByValue: Record<number, string> = {
|
||||
[RouteType.TRANSPORT_FLOOD]: 'Transport Flood',
|
||||
[RouteType.FLOOD]: 'Flood',
|
||||
[RouteType.DIRECT]: 'Direct',
|
||||
[RouteType.TRANSPORT_DIRECT]: 'Transport Direct',
|
||||
};
|
||||
|
||||
export const payloadValueByName = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => [name, value])
|
||||
@@ -56,31 +75,31 @@ export const payloadValueByName = Object.fromEntries(
|
||||
export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType }) => {
|
||||
switch (payloadType) {
|
||||
case PayloadType.REQUEST:
|
||||
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.RESPONSE:
|
||||
return <ArrowBackIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <ArrowBackIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.TEXT:
|
||||
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.ACK:
|
||||
return <ReplyIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <ReplyIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.ADVERT:
|
||||
return <SignalCellularAltIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <SignalCellularAltIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.GROUP_TEXT:
|
||||
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.GROUP_DATA:
|
||||
return <StorageIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <StorageIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.ANON_REQ:
|
||||
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.PATH:
|
||||
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.TRACE:
|
||||
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.MULTIPART:
|
||||
return <BrandingWatermarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <BrandingWatermarkIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.CONTROL:
|
||||
return <SensorsIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <SensorsIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
||||
default:
|
||||
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
|
||||
}
|
||||
@@ -282,6 +301,10 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
|
||||
const byNode = new Map<string, MeshCoreNodePoint>();
|
||||
|
||||
packets.forEach((packet) => {
|
||||
if (!packet.path || packet.path.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = packet.path[0].toString(16).padStart(2, '0');
|
||||
const existing = byNode.get(nodeId);
|
||||
|
||||
@@ -346,6 +369,8 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
raw,
|
||||
decodedPayload,
|
||||
payloadSummary: summarizePayload(packet.payload_type, decodedPayload, payloadBytes),
|
||||
snr: packet.snr,
|
||||
rssi: packet.rssi,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -383,6 +408,8 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
decodedPayload: message.decodedPayload,
|
||||
payloadSummary: '',
|
||||
radioName: message.radioName,
|
||||
snr: message.snr,
|
||||
rssi: message.rssi,
|
||||
};
|
||||
|
||||
// Extract details from raw packet
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
|
||||
import { Badge, Card, Stack } from 'react-bootstrap';
|
||||
|
||||
import PacketDissectionViewer from '../../components/PacketDissectionViewer';
|
||||
import type { Segment } from '../../protocols/dissection.types';
|
||||
|
||||
import type { MeshCorePacketRecord } from './MeshCoreContext';
|
||||
import {
|
||||
payloadDisplayByValue,
|
||||
payloadNameByValue,
|
||||
routeDisplayByValue,
|
||||
} from './MeshCoreData';
|
||||
|
||||
@@ -16,95 +19,13 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
|
||||
</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 buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => {
|
||||
const pathField = packet.raw.length > 1 ? packet.raw[1] : 0;
|
||||
const pathHashSize = bitSlice(pathField, 7, 6) + 1;
|
||||
const pathHashCount = bitSlice(pathField, 5, 0);
|
||||
@@ -112,194 +33,87 @@ const buildByteDissection = (packet: MeshCorePacketRecord): {
|
||||
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',
|
||||
};
|
||||
}
|
||||
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 (index < payloadOffset) {
|
||||
return {
|
||||
index,
|
||||
byte,
|
||||
zone: 'path',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
byte,
|
||||
zone: 'payload',
|
||||
};
|
||||
if (pathBytesAvailable > 0) {
|
||||
segments.push({
|
||||
name: 'Path Data',
|
||||
offset: 2,
|
||||
byteCount: pathBytesAvailable,
|
||||
attributes: [
|
||||
{
|
||||
byteWidth: pathBytesAvailable,
|
||||
type: 'bytes',
|
||||
name: `Path Hashes (${pathHashCount} × ${pathHashSize} bytes)`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
pathHashSize,
|
||||
pathHashCount,
|
||||
pathBytesAvailable,
|
||||
payloadOffset,
|
||||
};
|
||||
const payloadLength = packet.raw.length - payloadOffset;
|
||||
if (payloadLength > 0) {
|
||||
segments.push({
|
||||
name: 'Payload',
|
||||
offset: payloadOffset,
|
||||
byteCount: payloadLength,
|
||||
attributes: [
|
||||
{
|
||||
byteWidth: payloadLength,
|
||||
type: 'bytes',
|
||||
name: `Payload Data (${payloadLength} bytes)`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
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));
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
if (value && typeof value === 'object') {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
|
||||
@@ -397,20 +211,27 @@ const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ p
|
||||
<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>
|
||||
<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={`${payloadDisplayByValue[packet.payloadType] ?? 'Unknown'} (${packet.payloadType})`} />
|
||||
<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>
|
||||
<WireDissector packet={packet} />
|
||||
<Card body className="data-table-card">
|
||||
<PacketDissectionViewer
|
||||
rawPacket={packet.raw}
|
||||
segments={buildMeshCoreSegments(packet)}
|
||||
title="Packet Bytes (Wire View)"
|
||||
/>
|
||||
</Card>
|
||||
<PayloadDetails packet={packet} />
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Stream Preparation</h6>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react-bootstrap';
|
||||
|
||||
import {
|
||||
payloadDisplayByValue,
|
||||
payloadNameByValue,
|
||||
PayloadTypeIcon,
|
||||
routeDisplayByValue,
|
||||
} from './MeshCoreData';
|
||||
@@ -166,7 +166,7 @@ const MeshCorePacketFilters: React.FC<MeshCorePacketFiltersProps> = ({
|
||||
label="Payload Type"
|
||||
options={uniquePayloadTypes}
|
||||
selectedValues={filterPayloadTypes}
|
||||
getLabelForValue={(value) => payloadDisplayByValue[value] ?? `0x${value.toString(16)}`}
|
||||
getLabelForValue={(value) => payloadNameByValue[value] ?? `0x${value.toString(16)}`}
|
||||
getIconForValue={(value) => <PayloadTypeIcon payloadType={value} />}
|
||||
onToggle={onPayloadToggle}
|
||||
onSelectAll={onPayloadSelectAll}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
import { PayloadType } from '../../protocols/meshcore.types';
|
||||
import { PayloadType } from '../../types/protocol/meshcore.types';
|
||||
import type { MeshCorePacketRecord } from './MeshCoreContext';
|
||||
import {
|
||||
payloadDisplayByValue,
|
||||
payloadNameByValue,
|
||||
PayloadTypeIcon,
|
||||
} from './MeshCoreData';
|
||||
|
||||
@@ -131,12 +131,13 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{packet.snr !== undefined ? packet.snr.toFixed(1) : '-'} dB</td>
|
||||
<td>
|
||||
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
|
||||
{packet.hash}
|
||||
</button>
|
||||
</td>
|
||||
<td className="meshcore-payload-type-cell" title={payloadDisplayByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
|
||||
<td className="meshcore-payload-type-cell" title={payloadNameByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
|
||||
<PayloadTypeIcon payloadType={packet.payloadType} />
|
||||
</td>
|
||||
<td>{packet.payloadSummary}</td>
|
||||
@@ -154,9 +155,10 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
|
||||
{duplicatePacket.hash}
|
||||
</button>
|
||||
</td>
|
||||
<td className="meshcore-payload-type-cell" title={payloadDisplayByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
|
||||
<td className="meshcore-payload-type-cell" title={payloadNameByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
|
||||
<PayloadTypeIcon payloadType={duplicatePacket.payloadType} />
|
||||
</td>
|
||||
<td>{duplicatePacket.snr !== undefined ? duplicatePacket.snr.toFixed(1) : '-'} dB</td>
|
||||
<td>
|
||||
{getPathInfo(duplicatePacket).prefixes}
|
||||
</td>
|
||||
|
||||
@@ -214,6 +214,7 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
|
||||
<tr>
|
||||
<th style={{ width: '30px' }}></th>
|
||||
<th style={{ width: '100px' }}>Time</th>
|
||||
<th style={{ width: '60px' }}>SNR</th>
|
||||
<th style={{ width: '80px' }}>Hash</th>
|
||||
<th style={{ width: '50px' }}>Type</th>
|
||||
<th>Info</th>
|
||||
|
||||
@@ -282,6 +282,19 @@ it('should call decode method and return position data', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should decode uncompressed position with alternate symbol table (\\)', () => {
|
||||
const data = String.raw`W1AW>APRS:!4903.50N\07201.75W-Test message`;
|
||||
const frame = parseFrame(data);
|
||||
const decoded = frame.decode();
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe('position');
|
||||
|
||||
if (decoded && decoded.type === 'position') {
|
||||
expect(decoded.position.symbol.table).toBe('\\');
|
||||
expect(decoded.position.comment).toBe('Test message');
|
||||
}
|
||||
});
|
||||
it('should decode position with messaging capability', () => {
|
||||
const data = 'W1AW>APRS:=4903.50N/07201.75W-';
|
||||
const frame = parseFrame(data);
|
||||
@@ -428,6 +441,47 @@ it('should call decode method and return position data', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Object decoding', () => {
|
||||
it('should decode object payload with uncompressed position', () => {
|
||||
const data = 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object';
|
||||
const frame = parseFrame(data);
|
||||
const decoded = frame.decode();
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe('object');
|
||||
|
||||
if (decoded && decoded.type === 'object') {
|
||||
expect(decoded.name).toBe('OBJECT');
|
||||
expect(decoded.alive).toBe(true);
|
||||
expect(decoded.timestamp).toBeDefined();
|
||||
expect(decoded.position).toBeDefined();
|
||||
expect(typeof decoded.position.latitude).toBe('number');
|
||||
expect(typeof decoded.position.longitude).toBe('number');
|
||||
expect(decoded.position.comment).toBe('Test object');
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit object sections when emitSections is true', () => {
|
||||
const data = 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode(true);
|
||||
|
||||
expect(result.payload).not.toBeNull();
|
||||
expect(result.payload?.type).toBe('object');
|
||||
expect(result.sections.length).toBeGreaterThan(0);
|
||||
|
||||
const nameSection = result.sections.find((s) => s.name === 'Object Name');
|
||||
const stateSection = result.sections.find((s) => s.name === 'Object State');
|
||||
const timestampSection = result.sections.find((s) => s.name === 'timestamp');
|
||||
const positionSection = result.sections.find((s) => s.name === 'position');
|
||||
|
||||
expect(nameSection).toBeDefined();
|
||||
expect(stateSection).toBeDefined();
|
||||
expect(timestampSection).toBeDefined();
|
||||
expect(positionSection).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timestamp class', () => {
|
||||
@@ -962,3 +1016,110 @@ describe('Mic-E decoding', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packet dissection with sections', () => {
|
||||
it('should emit routing sections when emitSections is true', () => {
|
||||
const data = 'KB1ABC-5>APRS,WIDE1-1,WIDE2-2*:!4903.50N/07201.75W-Test';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode(true);
|
||||
|
||||
expect(result).toHaveProperty('payload');
|
||||
expect(result).toHaveProperty('sections');
|
||||
expect(result.sections).toBeDefined();
|
||||
expect(result.sections.length).toBeGreaterThan(0);
|
||||
|
||||
// Check routing section (capital R as implemented)
|
||||
const routingSection = result.sections.find(s => s.name === 'Routing');
|
||||
expect(routingSection).toBeDefined();
|
||||
expect(routingSection?.attributes).toBeDefined();
|
||||
expect(routingSection?.attributes?.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for source and destination attributes
|
||||
const sourceAttr = routingSection?.attributes?.find(a => a.name === 'Source address');
|
||||
expect(sourceAttr).toBeDefined();
|
||||
expect(sourceAttr?.byteWidth).toBeGreaterThan(0);
|
||||
|
||||
const destAttr = routingSection?.attributes?.find(a => a.name === 'Destination address');
|
||||
expect(destAttr).toBeDefined();
|
||||
expect(destAttr?.byteWidth).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should emit position payload sections when emitSections is true', () => {
|
||||
const data = 'CALL>APRS:!4903.50N/07201.75W-Test message';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode(true);
|
||||
|
||||
expect(result.payload).not.toBeNull();
|
||||
expect(result.payload?.type).toBe('position');
|
||||
|
||||
// Check if result has sections at top level
|
||||
expect(result.sections).toBeDefined();
|
||||
expect(result.sections?.length).toBeGreaterThan(0);
|
||||
|
||||
// Find position section
|
||||
const positionSection = result.sections?.find(s => s.name === 'position');
|
||||
expect(positionSection).toBeDefined();
|
||||
expect(positionSection?.byteCount).toBe(19); // Uncompressed position is 19 bytes
|
||||
expect(positionSection?.attributes).toBeDefined();
|
||||
expect(positionSection?.attributes?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not emit sections when emitSections is false or omitted', () => {
|
||||
const data = 'CALL>APRS:!4903.50N/07201.75W-Test';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode();
|
||||
|
||||
// Result should be just the DecodedPayload, not an object with payload and sections
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('position');
|
||||
expect((result as any).sections).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit timestamp section when present', () => {
|
||||
const data = 'CALL>APRS:@092345z4903.50N/07201.75W>';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode(true);
|
||||
|
||||
expect(result.payload?.type).toBe('position');
|
||||
|
||||
const timestampSection = result.sections?.find(s => s.name === 'timestamp');
|
||||
expect(timestampSection).toBeDefined();
|
||||
expect(timestampSection?.byteCount).toBe(7);
|
||||
expect(timestampSection?.offset).toBe(1); // After the '@' data type identifier
|
||||
expect(timestampSection?.attributes?.map(a => a.name)).toEqual([
|
||||
'Day (DD)',
|
||||
'Hour (HH)',
|
||||
'Minute (MM)',
|
||||
'Timezone Indicator',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should emit compressed position sections', () => {
|
||||
const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode(true);
|
||||
|
||||
expect(result.payload?.type).toBe('position');
|
||||
|
||||
const positionSection = result.sections?.find(s => s.name === 'position');
|
||||
expect(positionSection).toBeDefined();
|
||||
expect(positionSection?.byteCount).toBe(13); // Compressed position is 13 bytes
|
||||
|
||||
// Check for base91 encoded attributes
|
||||
const latAttr = positionSection?.attributes?.find(a => a.name === 'Latitude');
|
||||
expect(latAttr).toBeDefined();
|
||||
expect(latAttr?.type).toBe('base91');
|
||||
});
|
||||
|
||||
it('should emit comment section', () => {
|
||||
const data = 'CALL>APRS:!4903.50N/07201.75W-Test message';
|
||||
const frame = parseFrame(data);
|
||||
const result = frame.decode(true);
|
||||
|
||||
expect(result.payload?.type).toBe('position');
|
||||
const commentSection = result.sections?.find(s => s.name === 'comment');
|
||||
expect(commentSection).toBeDefined();
|
||||
expect(commentSection?.byteCount).toBe('Test message'.length);
|
||||
expect(commentSection?.attributes?.[0]?.name).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Address, Frame as IFrame, DecodedPayload, Timestamp as ITimestamp } from "./aprs.types"
|
||||
import type { Address, Frame as IFrame, DecodedPayload, Timestamp as ITimestamp } from "../types/protocol/aprs.types"
|
||||
import type { Attribute, Segment } from "../types/protocol/dissection.types"
|
||||
import { base91ToNumber } from "../libs/base91"
|
||||
|
||||
export class Timestamp implements ITimestamp {
|
||||
@@ -117,12 +118,14 @@ export class Frame implements IFrame {
|
||||
destination: Address;
|
||||
path: Address[];
|
||||
payload: string;
|
||||
private _routingSection?: Segment;
|
||||
|
||||
constructor(source: Address, destination: Address, path: Address[], payload: string) {
|
||||
constructor(source: Address, destination: Address, path: Address[], payload: string, routingSection?: Segment) {
|
||||
this.source = source;
|
||||
this.destination = destination;
|
||||
this.path = path;
|
||||
this.payload = payload;
|
||||
this._routingSection = routingSection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,14 +136,45 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the APRS payload based on its data type identifier
|
||||
* Get or build routing section from cached data
|
||||
*/
|
||||
decode(): DecodedPayload | null {
|
||||
private getRoutingSection(): Segment | undefined {
|
||||
return this._routingSection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the APRS payload based on its data type identifier
|
||||
* Returns the decoded payload with optional sections for packet dissection
|
||||
*/
|
||||
decode(): DecodedPayload | null;
|
||||
decode(emitSections: true): { payload: DecodedPayload | null; sections: Segment[] };
|
||||
decode(emitSections?: boolean): DecodedPayload | null | { payload: DecodedPayload | null; sections: Segment[] } {
|
||||
if (!this.payload) {
|
||||
if (emitSections) {
|
||||
const sections: Segment[] = [];
|
||||
const routingSection = this.getRoutingSection();
|
||||
if (routingSection) {
|
||||
sections.push(routingSection);
|
||||
|
||||
// Add data type identifier section
|
||||
sections.push({
|
||||
name: 'Data Type',
|
||||
offset: routingSection ? routingSection.byteCount : 0,
|
||||
byteCount: 1,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'char', name: 'Identifier' },
|
||||
],
|
||||
});
|
||||
|
||||
}
|
||||
return { payload: null, sections };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataType = this.getDataTypeIdentifier();
|
||||
let decodedPayload: DecodedPayload | null = null;
|
||||
let payloadSections: Segment[] | undefined = undefined;
|
||||
|
||||
// TODO: Implement full decoding logic for each payload type
|
||||
switch (dataType) {
|
||||
@@ -148,69 +182,107 @@ export class Frame implements IFrame {
|
||||
case '=': // Position without timestamp, with messaging
|
||||
case '/': // Position with timestamp, no messaging
|
||||
case '@': // Position with timestamp, with messaging
|
||||
return this.decodePosition(dataType);
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodePosition(dataType, emitSections));
|
||||
break;
|
||||
|
||||
case '`': // Mic-E current
|
||||
case "'": // Mic-E old
|
||||
return this.decodeMicE();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeMicE(emitSections));
|
||||
break;
|
||||
|
||||
case ':': // Message
|
||||
return this.decodeMessage();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeMessage(emitSections));
|
||||
break;
|
||||
|
||||
case ';': // Object
|
||||
return this.decodeObject();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeObject(emitSections));
|
||||
break;
|
||||
|
||||
case ')': // Item
|
||||
return this.decodeItem();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeItem(emitSections));
|
||||
break;
|
||||
|
||||
case '>': // Status
|
||||
return this.decodeStatus();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeStatus(emitSections));
|
||||
break;
|
||||
|
||||
case '?': // Query
|
||||
return this.decodeQuery();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeQuery(emitSections));
|
||||
break;
|
||||
|
||||
case 'T': // Telemetry
|
||||
return this.decodeTelemetry();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeTelemetry(emitSections));
|
||||
break;
|
||||
|
||||
case '_': // Weather without position
|
||||
return this.decodeWeather();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeWeather(emitSections));
|
||||
break;
|
||||
|
||||
case '$': // Raw GPS
|
||||
return this.decodeRawGPS();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeRawGPS(emitSections));
|
||||
break;
|
||||
|
||||
case '<': // Station capabilities
|
||||
return this.decodeCapabilities();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeCapabilities(emitSections));
|
||||
break;
|
||||
|
||||
case '{': // User-defined
|
||||
return this.decodeUserDefined();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeUserDefined(emitSections));
|
||||
break;
|
||||
|
||||
case '}': // Third-party
|
||||
return this.decodeThirdParty();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeThirdParty(emitSections));
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
decodedPayload = null;
|
||||
}
|
||||
|
||||
private decodePosition(dataType: string): DecodedPayload | null {
|
||||
if (emitSections) {
|
||||
const sections: Segment[] = [];
|
||||
const routingSection = this.getRoutingSection();
|
||||
if (routingSection) {
|
||||
sections.push(routingSection);
|
||||
}
|
||||
if (payloadSections) {
|
||||
sections.push(...payloadSections);
|
||||
}
|
||||
return { payload: decodedPayload, sections };
|
||||
}
|
||||
|
||||
return decodedPayload;
|
||||
}
|
||||
|
||||
private decodePosition(dataType: string, emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
try {
|
||||
const hasTimestamp = dataType === '/' || dataType === '@';
|
||||
const messaging = dataType === '=' || dataType === '@';
|
||||
let offset = 1; // Skip data type identifier
|
||||
|
||||
// Build sections as we parse
|
||||
const sections: Segment[] = emitSections ? [] : [];
|
||||
|
||||
let timestamp: Timestamp | undefined = undefined;
|
||||
|
||||
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHHMM)
|
||||
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM)
|
||||
if (hasTimestamp) {
|
||||
if (this.payload.length < 8) return null;
|
||||
if (this.payload.length < 8) return { payload: null };
|
||||
const timestampOffset = offset;
|
||||
const timeStr = this.payload.substring(offset, offset + 7);
|
||||
timestamp = this.parseTimestamp(timeStr);
|
||||
const { timestamp: parsedTimestamp, section: timestampSection } = this.parseTimestamp(timeStr, emitSections, timestampOffset);
|
||||
timestamp = parsedTimestamp;
|
||||
|
||||
if (timestampSection) {
|
||||
sections.push(timestampSection);
|
||||
}
|
||||
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
if (this.payload.length < offset + 19) return null;
|
||||
if (this.payload.length < offset + 19) return { payload: null };
|
||||
|
||||
// Check if compressed format
|
||||
const positionOffset = offset;
|
||||
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
|
||||
|
||||
let position: any;
|
||||
@@ -218,8 +290,8 @@ export class Frame implements IFrame {
|
||||
|
||||
if (isCompressed) {
|
||||
// Compressed format: /YYYYXXXX$csT
|
||||
const compressed = this.parseCompressedPosition(this.payload.substring(offset));
|
||||
if (!compressed) return null;
|
||||
const { position: compressed, section: compressedSection } = this.parseCompressedPosition(this.payload.substring(offset), emitSections, positionOffset);
|
||||
if (!compressed) return { payload: null };
|
||||
|
||||
position = {
|
||||
latitude: compressed.latitude,
|
||||
@@ -231,12 +303,16 @@ export class Frame implements IFrame {
|
||||
position.altitude = compressed.altitude;
|
||||
}
|
||||
|
||||
if (compressedSection) {
|
||||
sections.push(compressedSection);
|
||||
}
|
||||
|
||||
offset += 13; // Compressed position is 13 chars
|
||||
comment = this.payload.substring(offset);
|
||||
} else {
|
||||
// Uncompressed format: DDMMmmH/DDDMMmmH$
|
||||
const uncompressed = this.parseUncompressedPosition(this.payload.substring(offset));
|
||||
if (!uncompressed) return null;
|
||||
const { position: uncompressed, section: uncompressedSection } = this.parseUncompressedPosition(this.payload.substring(offset), emitSections, positionOffset);
|
||||
if (!uncompressed) return { payload: null };
|
||||
|
||||
position = {
|
||||
latitude: uncompressed.latitude,
|
||||
@@ -248,6 +324,10 @@ export class Frame implements IFrame {
|
||||
position.ambiguity = uncompressed.ambiguity;
|
||||
}
|
||||
|
||||
if (uncompressedSection) {
|
||||
sections.push(uncompressedSection);
|
||||
}
|
||||
|
||||
offset += 19; // Uncompressed position is 19 chars
|
||||
comment = this.payload.substring(offset);
|
||||
}
|
||||
@@ -260,27 +340,45 @@ export class Frame implements IFrame {
|
||||
|
||||
if (comment) {
|
||||
position.comment = comment;
|
||||
|
||||
// Emit comment section as we parse
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'comment',
|
||||
offset: offset,
|
||||
byteCount: comment.length,
|
||||
attributes: [
|
||||
{ byteWidth: comment.length, type: 'string', name: 'text' },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const payload: any = {
|
||||
type: 'position',
|
||||
timestamp,
|
||||
position,
|
||||
messaging,
|
||||
};
|
||||
|
||||
if (emitSections) {
|
||||
return { payload, sections };
|
||||
}
|
||||
|
||||
return { payload };
|
||||
} catch (e) {
|
||||
return null;
|
||||
return { payload: null };
|
||||
}
|
||||
}
|
||||
|
||||
private parseTimestamp(timeStr: string): Timestamp | undefined {
|
||||
if (timeStr.length !== 7) return undefined;
|
||||
private parseTimestamp(timeStr: string, emitSections: boolean = false, offset: number = 0): { timestamp: Timestamp | undefined; section?: Segment } {
|
||||
if (timeStr.length !== 7) return { timestamp: undefined };
|
||||
|
||||
const timeType = timeStr.charAt(6);
|
||||
|
||||
if (timeType === 'z') {
|
||||
// DHM format: Day-Hour-Minute (UTC)
|
||||
return new Timestamp(
|
||||
const timestamp = new Timestamp(
|
||||
parseInt(timeStr.substring(2, 4), 10),
|
||||
parseInt(timeStr.substring(4, 6), 10),
|
||||
'DHM',
|
||||
@@ -289,9 +387,23 @@ export class Frame implements IFrame {
|
||||
zulu: true,
|
||||
}
|
||||
);
|
||||
|
||||
const section = emitSections ? {
|
||||
name: 'timestamp',
|
||||
offset,
|
||||
byteCount: 7,
|
||||
attributes: [
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Day (DD)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Hour (HH)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Minute (MM)' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Timezone Indicator' },
|
||||
],
|
||||
} : undefined;
|
||||
|
||||
return { timestamp, section };
|
||||
} else if (timeType === 'h') {
|
||||
// HMS format: Hour-Minute-Second (UTC)
|
||||
return new Timestamp(
|
||||
const timestamp = new Timestamp(
|
||||
parseInt(timeStr.substring(0, 2), 10),
|
||||
parseInt(timeStr.substring(2, 4), 10),
|
||||
'HMS',
|
||||
@@ -300,9 +412,23 @@ export class Frame implements IFrame {
|
||||
zulu: true,
|
||||
}
|
||||
);
|
||||
|
||||
const section = emitSections ? {
|
||||
name: 'timestamp',
|
||||
offset,
|
||||
byteCount: 7,
|
||||
attributes: [
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Hour (HH)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Minute (MM)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Second (SS)' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Timezone Indicator' },
|
||||
],
|
||||
} : undefined;
|
||||
|
||||
return { timestamp, section };
|
||||
} else if (timeType === '/') {
|
||||
// MDHM format: Month-Day-Hour-Minute (local)
|
||||
return new Timestamp(
|
||||
const timestamp = new Timestamp(
|
||||
parseInt(timeStr.substring(4, 6), 10),
|
||||
parseInt(timeStr.substring(6, 8), 10),
|
||||
'MDHM',
|
||||
@@ -312,18 +438,37 @@ export class Frame implements IFrame {
|
||||
zulu: false,
|
||||
}
|
||||
);
|
||||
|
||||
const section = emitSections ? {
|
||||
name: 'timestamp',
|
||||
offset,
|
||||
byteCount: 7,
|
||||
attributes: [
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Month (MM)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Day (DD)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Hour (HH)' },
|
||||
{ byteWidth: 2, type: 'string' as const, name: 'Minute (MM)' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Timezone Indicator' },
|
||||
],
|
||||
} : undefined;
|
||||
|
||||
return { timestamp, section };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return { timestamp: undefined };
|
||||
}
|
||||
|
||||
private isCompressedPosition(data: string): boolean {
|
||||
if (data.length < 13) return false;
|
||||
|
||||
// Uncompressed format has / at position 8 (symbol table separator)
|
||||
// Format: DDMMmmH/DDDMMmmH$ where / is at position 8
|
||||
if (data.length >= 19 && data.charAt(8) === '/') {
|
||||
return false; // It's uncompressed
|
||||
// First prefer uncompressed detection by attempting an uncompressed parse.
|
||||
// Uncompressed APRS positions do not have a fixed symbol table separator;
|
||||
// position 8 is a symbol table identifier and may vary.
|
||||
if (data.length >= 19) {
|
||||
const uncompressed = this.parseUncompressedPosition(data, false, 0);
|
||||
if (uncompressed.position) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For compressed format, check if the position part looks like base-91 encoded data
|
||||
@@ -340,8 +485,8 @@ export class Frame implements IFrame {
|
||||
lon2 >= 33 && lon2 <= 124;
|
||||
}
|
||||
|
||||
private parseCompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; altitude?: number } | null {
|
||||
if (data.length < 13) return null;
|
||||
private parseCompressedPosition(data: string, emitSections: boolean = false, offset: number = 0): { position: { latitude: number; longitude: number; symbol: any; altitude?: number } | null; section?: Segment } {
|
||||
if (data.length < 13) return { position: null };
|
||||
|
||||
const symbolTable = data.charAt(0);
|
||||
const symbolCode = data.charAt(9);
|
||||
@@ -378,14 +523,29 @@ export class Frame implements IFrame {
|
||||
result.altitude = altFeet * 0.3048; // Convert to meters
|
||||
}
|
||||
|
||||
return result;
|
||||
const section = emitSections ? {
|
||||
name: 'position',
|
||||
offset,
|
||||
byteCount: 13,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Symbol Table' },
|
||||
{ byteWidth: 4, type: 'base91' as const, name: 'Latitude' },
|
||||
{ byteWidth: 4, type: 'base91' as const, name: 'Longitude' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Symbol Code' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Course/Speed Type' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Course/Speed Value' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Altitude' },
|
||||
],
|
||||
} : undefined;
|
||||
|
||||
return { position: result, section };
|
||||
} catch (e) {
|
||||
return null;
|
||||
return { position: null };
|
||||
}
|
||||
}
|
||||
|
||||
private parseUncompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null {
|
||||
if (data.length < 19) return null;
|
||||
private parseUncompressedPosition(data: string, emitSections: boolean = false, offset: number = 0): { position: { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null; section?: Segment } {
|
||||
if (data.length < 19) return { position: null };
|
||||
|
||||
// Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code
|
||||
// Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code)
|
||||
@@ -415,8 +575,8 @@ export class Frame implements IFrame {
|
||||
const latMin = parseFloat(latStrNormalized.substring(2, 7));
|
||||
const latHem = latStrNormalized.charAt(7);
|
||||
|
||||
if (isNaN(latDeg) || isNaN(latMin)) return null;
|
||||
if (latHem !== 'N' && latHem !== 'S') return null;
|
||||
if (isNaN(latDeg) || isNaN(latMin)) return { position: null };
|
||||
if (latHem !== 'N' && latHem !== 'S') return { position: null };
|
||||
|
||||
let latitude = latDeg + (latMin / 60);
|
||||
if (latHem === 'S') latitude = -latitude;
|
||||
@@ -426,8 +586,8 @@ export class Frame implements IFrame {
|
||||
const lonMin = parseFloat(lonStrNormalized.substring(3, 8));
|
||||
const lonHem = lonStrNormalized.charAt(8);
|
||||
|
||||
if (isNaN(lonDeg) || isNaN(lonMin)) return null;
|
||||
if (lonHem !== 'E' && lonHem !== 'W') return null;
|
||||
if (isNaN(lonDeg) || isNaN(lonMin)) return { position: null };
|
||||
if (lonHem !== 'E' && lonHem !== 'W') return { position: null };
|
||||
|
||||
let longitude = lonDeg + (lonMin / 60);
|
||||
if (lonHem === 'W') longitude = -longitude;
|
||||
@@ -445,20 +605,35 @@ export class Frame implements IFrame {
|
||||
result.ambiguity = ambiguity;
|
||||
}
|
||||
|
||||
return result;
|
||||
const section = emitSections ? {
|
||||
name: 'position',
|
||||
offset,
|
||||
byteCount: 19,
|
||||
attributes: [
|
||||
{ byteWidth: 8, type: 'string' as const, name: 'Latitude' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Symbol Table' },
|
||||
{ byteWidth: 9, type: 'string' as const, name: 'Longitude' },
|
||||
{ byteWidth: 1, type: 'char' as const, name: 'Symbol Code' },
|
||||
],
|
||||
} : undefined;
|
||||
|
||||
return { position: result, section };
|
||||
}
|
||||
|
||||
private decodeMicE(): DecodedPayload | null {
|
||||
private decodeMicE(emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
try {
|
||||
// TODO: Add section emission support when emitSections is true
|
||||
// For now, Mic-E returns payload without sections
|
||||
|
||||
// Mic-E encodes position in both destination address and information field
|
||||
const dest = this.destination.call;
|
||||
|
||||
if (dest.length < 6) return null;
|
||||
if (this.payload.length < 9) return null; // Need at least data type + 8 bytes
|
||||
if (dest.length < 6) return { payload: null };
|
||||
if (this.payload.length < 9) return { payload: null }; // Need at least data type + 8 bytes
|
||||
|
||||
// Decode latitude from destination address (6 characters)
|
||||
const latResult = this.decodeMicELatitude(dest);
|
||||
if (!latResult) return null;
|
||||
if (!latResult) return { payload: null };
|
||||
|
||||
const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult;
|
||||
|
||||
@@ -503,7 +678,7 @@ export class Frame implements IFrame {
|
||||
const speedKmh = speed * 1.852;
|
||||
|
||||
// Symbol code and table
|
||||
if (this.payload.length < offset + 2) return null;
|
||||
if (this.payload.length < offset + 2) return { payload: null };
|
||||
const symbolCode = this.payload.charAt(offset);
|
||||
const symbolTable = this.payload.charAt(offset + 1);
|
||||
offset += 2;
|
||||
@@ -565,9 +740,9 @@ export class Frame implements IFrame {
|
||||
result.position.comment = comment;
|
||||
}
|
||||
|
||||
return result;
|
||||
return { payload: result };
|
||||
} catch (e) {
|
||||
return null;
|
||||
return { payload: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,59 +841,182 @@ export class Frame implements IFrame {
|
||||
};
|
||||
}
|
||||
|
||||
private decodeMessage(): DecodedPayload | null {
|
||||
// TODO: Implement message decoding
|
||||
return null;
|
||||
private decodeMessage(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement message decoding with section emission
|
||||
// When implemented, build sections during parsing like decodePosition does
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeObject(): DecodedPayload | null {
|
||||
// TODO: Implement object decoding
|
||||
return null;
|
||||
private decodeObject(emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
try {
|
||||
// Object format: ;AAAAAAAAAcDDHHMMzDDMM.hhN/DDDMM.hhW$comment
|
||||
// ^ data type
|
||||
// 9-char name
|
||||
// alive (*) / killed (_)
|
||||
if (this.payload.length < 18) return { payload: null }; // 1 + 9 + 1 + 7 minimum
|
||||
|
||||
let offset = 1; // Skip data type identifier ';'
|
||||
const sections: Segment[] = emitSections ? [] : [];
|
||||
|
||||
const rawName = this.payload.substring(offset, offset + 9);
|
||||
const name = rawName.trimEnd();
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Object Name',
|
||||
offset,
|
||||
byteCount: 9,
|
||||
attributes: [
|
||||
{ byteWidth: 9, type: 'string', name: 'Name' },
|
||||
],
|
||||
stringOnly: true,
|
||||
});
|
||||
}
|
||||
offset += 9;
|
||||
|
||||
const stateChar = this.payload.charAt(offset);
|
||||
if (stateChar !== '*' && stateChar !== '_') {
|
||||
return { payload: null };
|
||||
}
|
||||
const alive = stateChar === '*';
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Object State',
|
||||
offset,
|
||||
byteCount: 1,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'char', name: 'State (* alive, _ killed)' },
|
||||
],
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
|
||||
const timeStr = this.payload.substring(offset, offset + 7);
|
||||
const { timestamp, section: timestampSection } = this.parseTimestamp(timeStr, emitSections, offset);
|
||||
if (!timestamp) {
|
||||
return { payload: null };
|
||||
}
|
||||
if (timestampSection) {
|
||||
sections.push(timestampSection);
|
||||
}
|
||||
offset += 7;
|
||||
|
||||
const positionOffset = offset;
|
||||
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
|
||||
|
||||
let position: { latitude: number; longitude: number; symbol: any; ambiguity?: number; altitude?: number; comment?: string } | null = null;
|
||||
let consumed = 0;
|
||||
|
||||
if (isCompressed) {
|
||||
const { position: compressed, section: compressedSection } = this.parseCompressedPosition(this.payload.substring(offset), emitSections, positionOffset);
|
||||
if (!compressed) return { payload: null };
|
||||
|
||||
position = {
|
||||
latitude: compressed.latitude,
|
||||
longitude: compressed.longitude,
|
||||
symbol: compressed.symbol,
|
||||
altitude: compressed.altitude,
|
||||
};
|
||||
consumed = 13;
|
||||
|
||||
if (compressedSection) {
|
||||
sections.push(compressedSection);
|
||||
}
|
||||
} else {
|
||||
const { position: uncompressed, section: uncompressedSection } = this.parseUncompressedPosition(this.payload.substring(offset), emitSections, positionOffset);
|
||||
if (!uncompressed) return { payload: null };
|
||||
|
||||
position = {
|
||||
latitude: uncompressed.latitude,
|
||||
longitude: uncompressed.longitude,
|
||||
symbol: uncompressed.symbol,
|
||||
ambiguity: uncompressed.ambiguity,
|
||||
};
|
||||
consumed = 19;
|
||||
|
||||
if (uncompressedSection) {
|
||||
sections.push(uncompressedSection);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeItem(): DecodedPayload | null {
|
||||
// TODO: Implement item decoding
|
||||
return null;
|
||||
offset += consumed;
|
||||
const comment = this.payload.substring(offset);
|
||||
if (comment) {
|
||||
position.comment = comment;
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Comment',
|
||||
offset,
|
||||
byteCount: comment.length,
|
||||
attributes: [
|
||||
{ byteWidth: comment.length, type: 'string', name: 'text' },
|
||||
],
|
||||
stringOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private decodeStatus(): DecodedPayload | null {
|
||||
// TODO: Implement status decoding
|
||||
return null;
|
||||
const payload: any = {
|
||||
type: 'object',
|
||||
name,
|
||||
timestamp,
|
||||
alive,
|
||||
position,
|
||||
};
|
||||
|
||||
if (emitSections) {
|
||||
return { payload, sections };
|
||||
}
|
||||
|
||||
private decodeQuery(): DecodedPayload | null {
|
||||
// TODO: Implement query decoding
|
||||
return null;
|
||||
return { payload };
|
||||
} catch (e) {
|
||||
return { payload: null };
|
||||
}
|
||||
}
|
||||
|
||||
private decodeTelemetry(): DecodedPayload | null {
|
||||
// TODO: Implement telemetry decoding
|
||||
return null;
|
||||
private decodeItem(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement item decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeWeather(): DecodedPayload | null {
|
||||
// TODO: Implement weather decoding
|
||||
return null;
|
||||
private decodeStatus(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement status decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeRawGPS(): DecodedPayload | null {
|
||||
// TODO: Implement raw GPS decoding
|
||||
return null;
|
||||
private decodeQuery(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement query decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeCapabilities(): DecodedPayload | null {
|
||||
// TODO: Implement capabilities decoding
|
||||
return null;
|
||||
private decodeTelemetry(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement telemetry decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeUserDefined(): DecodedPayload | null {
|
||||
// TODO: Implement user-defined decoding
|
||||
return null;
|
||||
private decodeWeather(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement weather decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeThirdParty(): DecodedPayload | null {
|
||||
// TODO: Implement third-party decoding
|
||||
return null;
|
||||
private decodeRawGPS(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement raw GPS decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeCapabilities(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement capabilities decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeUserDefined(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement user-defined decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
private decodeThirdParty(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } {
|
||||
// TODO: Implement third-party decoding with section emission
|
||||
return { payload: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,10 +1033,54 @@ export const parseFrame = (data: string): Frame => {
|
||||
throw new Error('APRS: invalid addresses in route');
|
||||
}
|
||||
|
||||
const source = parseAddress(parts[0]);
|
||||
const destinationAndPath = parts[1].split(',');
|
||||
const destination = parseAddress(destinationAndPath[0]);
|
||||
const path = destinationAndPath.slice(1).map(addr => parseAddress(addr));
|
||||
// Parse source - track byte offset as we parse
|
||||
let offset = 0;
|
||||
const sourceStr = parts[0];
|
||||
const source = parseAddress(sourceStr);
|
||||
|
||||
return new Frame(source, destination, path, payload);
|
||||
offset += sourceStr.length + 1; // +1 for '>'
|
||||
|
||||
// Parse destination and path
|
||||
const destinationAndPath = parts[1].split(',');
|
||||
const destinationStr = destinationAndPath[0];
|
||||
const destination = parseAddress(destinationStr);
|
||||
|
||||
offset += destinationStr.length;
|
||||
|
||||
// Parse path
|
||||
const path: Address[] = [];
|
||||
const pathAttributes: Attribute[] = [];
|
||||
for (let i = 1; i < destinationAndPath.length; i++) {
|
||||
offset += 1; // +1 for ','
|
||||
const pathStr = destinationAndPath[i];
|
||||
path.push(parseAddress(pathStr));
|
||||
|
||||
pathAttributes.push({
|
||||
name: `Path separator ${i}`,
|
||||
byteWidth: 1,
|
||||
type: 'string'
|
||||
});
|
||||
pathAttributes.push({
|
||||
name: `Repeater ${i}`,
|
||||
byteWidth: pathStr.length,
|
||||
type: 'string'
|
||||
});
|
||||
offset += pathStr.length;
|
||||
}
|
||||
|
||||
const routingSection: Segment = {
|
||||
name: 'Routing',
|
||||
offset: 0,
|
||||
byteCount: offset + 1, // +1 for ':' separator after path
|
||||
stringOnly: true,
|
||||
attributes: [
|
||||
{ byteWidth: sourceStr.length, type: 'string', name: 'Source address' },
|
||||
{ byteWidth: 1, type: 'char', name: 'Route separator' },
|
||||
{ byteWidth: destinationStr.length, type: 'string', name: 'Destination address' },
|
||||
...pathAttributes,
|
||||
{ byteWidth: 1, type: 'char', name: 'Payload separator' },
|
||||
],
|
||||
};
|
||||
|
||||
return new Frame(source, destination, path, payload, routingSection);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import type {
|
||||
ResponsePayload,
|
||||
TextPayload,
|
||||
TracePayload,
|
||||
} from './meshcore.types';
|
||||
import { AdvertisementFlags, PayloadType, RouteType } from './meshcore.types';
|
||||
} from '../types/protocol/meshcore.types';
|
||||
import { AdvertisementFlags, PayloadType, RouteType } from '../types/protocol/meshcore.types';
|
||||
|
||||
describe('GroupSecret', () => {
|
||||
describe('fromName', () => {
|
||||
@@ -651,4 +651,63 @@ describe('Packet', () => {
|
||||
expect(packet.getPathBytesLength()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packet dissection with sections', () => {
|
||||
it('should emit framework sections when emitSections is true', () => {
|
||||
const payloadData = new Uint8Array([0x01, 0x02, 0x03]);
|
||||
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData);
|
||||
|
||||
const packet = new Packet(packetData, true);
|
||||
const result = packet.decode(true);
|
||||
|
||||
expect(result.sections).toBeDefined();
|
||||
expect(result.sections?.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have Framework section
|
||||
const frameworkSection = result.sections?.find(s => s.name === 'Framework');
|
||||
expect(frameworkSection).toBeDefined();
|
||||
expect(frameworkSection?.children).toBeDefined();
|
||||
|
||||
// Should have Header section within Framework
|
||||
const headerSection = frameworkSection?.children?.find(s => s.name === 'Header');
|
||||
expect(headerSection).toBeDefined();
|
||||
expect(headerSection?.byteCount).toBe(1);
|
||||
expect(headerSection?.attributes).toBeDefined();
|
||||
|
||||
// Should have Path Length section
|
||||
const pathLengthSection = frameworkSection?.children?.find(s => s.name === 'Path Length');
|
||||
expect(pathLengthSection).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit payload sections when emitSections is true', () => {
|
||||
const payloadData = new Uint8Array([
|
||||
0x55, // dstHash
|
||||
0x66, // srcHash
|
||||
0xFF, 0xFF, // cipherMAC
|
||||
0xAA, 0xBB // cipherText
|
||||
]);
|
||||
const packetData = createPacketData(RouteType.FLOOD, PayloadType.REQUEST, payloadData);
|
||||
|
||||
const packet = new Packet(packetData, true);
|
||||
const result = packet.decode(true);
|
||||
|
||||
expect(result.sections).toBeDefined();
|
||||
|
||||
// Should have Payload section
|
||||
const payloadSection = result.sections?.find(s => s.name === 'Payload');
|
||||
expect(payloadSection).toBeDefined();
|
||||
expect(payloadSection?.attributes).toBeDefined();
|
||||
expect(payloadSection?.attributes?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not emit sections when emitSections is false or omitted', () => {
|
||||
const payloadData = new Uint8Array([0x01, 0x02]);
|
||||
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData);
|
||||
|
||||
const packet = new Packet(packetData);
|
||||
const result = packet.decode();
|
||||
|
||||
expect(result.sections).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sha256, sha512 } from '@noble/hashes/sha2.js';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||
|
||||
// Local types
|
||||
import type { Attribute, Segment } from '../types/protocol/dissection.types';
|
||||
import type {
|
||||
AckPayload,
|
||||
AnonReqPayload,
|
||||
@@ -29,7 +30,7 @@ import type {
|
||||
ResponsePayload,
|
||||
TextPayload,
|
||||
TracePayload,
|
||||
} from './meshcore.types';
|
||||
} from '../types/protocol/meshcore.types';
|
||||
|
||||
// Local imports
|
||||
import { base64ToBytes, BufferReader, constantTimeEqual } from '../util';
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
BasePacket,
|
||||
PayloadType,
|
||||
RouteType,
|
||||
} from './meshcore.types';
|
||||
} from '../types/protocol/meshcore.types';
|
||||
|
||||
const MAX_PATH_SIZE = 64;
|
||||
|
||||
@@ -56,33 +57,76 @@ export const hasTransportCodes = (routeType: RouteType): boolean => {
|
||||
}
|
||||
|
||||
export class Packet extends BasePacket {
|
||||
constructor(data?: Uint8Array | string) {
|
||||
private _frameworkSection?: Segment;
|
||||
|
||||
constructor(data?: Uint8Array | string, emitSections = false) {
|
||||
super();
|
||||
if (typeof data !== 'undefined') {
|
||||
if (typeof data === 'string') {
|
||||
data = base64ToBytes(data);
|
||||
}
|
||||
this.parse(data);
|
||||
this.parse(data, emitSections);
|
||||
}
|
||||
}
|
||||
|
||||
public parse(data: Uint8Array) {
|
||||
public parse(data: Uint8Array, emitSections = false) {
|
||||
let offset = 0;
|
||||
const frameworkChildren: Segment[] = [];
|
||||
|
||||
const header = data[0];
|
||||
this.routeType = (header >> 0) & 0x03;
|
||||
this.payloadType = (header >> 2) & 0x0F;
|
||||
this.version = (header >> 6) & 0x03;
|
||||
|
||||
// Build header section
|
||||
if (emitSections) {
|
||||
frameworkChildren.push({
|
||||
name: 'Header',
|
||||
offset,
|
||||
byteCount: 1,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'uint8', name: 'Header Byte' },
|
||||
],
|
||||
});
|
||||
}
|
||||
offset++;
|
||||
|
||||
let index = 1;
|
||||
if (hasTransportCodes(this.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);
|
||||
|
||||
if (emitSections) {
|
||||
frameworkChildren.push({
|
||||
name: 'Transport Codes',
|
||||
offset,
|
||||
byteCount: 4,
|
||||
attributes: [
|
||||
{ byteWidth: 2, type: 'uint16le', name: 'Transport Code 1' },
|
||||
{ byteWidth: 2, type: 'uint16le', name: 'Transport Code 2' },
|
||||
],
|
||||
});
|
||||
}
|
||||
index += 4;
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
this.pathLength = data[index];
|
||||
if (emitSections) {
|
||||
frameworkChildren.push({
|
||||
name: 'Path Length',
|
||||
offset,
|
||||
byteCount: 1,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'uint8', name: 'Path Length' },
|
||||
],
|
||||
});
|
||||
}
|
||||
index++;
|
||||
offset++;
|
||||
|
||||
if (!this.isValidPathLength()) {
|
||||
throw new Error(`MeshCore: invalid path length ${this.pathLength}`)
|
||||
}
|
||||
@@ -94,10 +138,31 @@ export class Packet extends BasePacket {
|
||||
index++;
|
||||
}
|
||||
|
||||
if (emitSections) {
|
||||
frameworkChildren.push({
|
||||
name: 'Path',
|
||||
offset,
|
||||
byteCount: pathBytesLength,
|
||||
attributes: [
|
||||
{ byteWidth: pathBytesLength, type: 'uint8', name: 'Path Bytes' },
|
||||
],
|
||||
});
|
||||
}
|
||||
offset += pathBytesLength;
|
||||
|
||||
if (index >= data.length) {
|
||||
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);
|
||||
for (let i = 0; i < payloadBytesLength; i++) {
|
||||
@@ -110,6 +175,10 @@ export class Packet extends BasePacket {
|
||||
return this.parse(base64ToBytes(data));
|
||||
}
|
||||
|
||||
public getFrameworkSection(): Segment | undefined {
|
||||
return this._frameworkSection;
|
||||
}
|
||||
|
||||
private isValidPathLength(): boolean {
|
||||
const hashCount = this.getPathHashCount();
|
||||
const hashSize = this.getPathHashSize();
|
||||
@@ -139,160 +208,372 @@ export class Packet extends BasePacket {
|
||||
return hash.slice(0, 8);
|
||||
}
|
||||
|
||||
public decode(): Payload {
|
||||
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;
|
||||
|
||||
switch (this.payloadType) {
|
||||
case PayloadType.REQUEST:
|
||||
return this.decodeRequest();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeRequest(emitSections));
|
||||
break;
|
||||
case PayloadType.RESPONSE:
|
||||
return this.decodeResponse();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeResponse(emitSections));
|
||||
break;
|
||||
case PayloadType.TEXT:
|
||||
return this.decodeText();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeText(emitSections));
|
||||
break;
|
||||
case PayloadType.ACK:
|
||||
return this.decodeAck();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeAck(emitSections));
|
||||
break;
|
||||
case PayloadType.ADVERT:
|
||||
return this.decodeAdvert();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeAdvert(emitSections));
|
||||
break;
|
||||
case PayloadType.GROUP_TEXT:
|
||||
return this.decodeGroupText();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupText(emitSections));
|
||||
break;
|
||||
case PayloadType.GROUP_DATA:
|
||||
return this.decodeGroupData();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupData(emitSections));
|
||||
break;
|
||||
case PayloadType.ANON_REQ:
|
||||
return this.decodeAnonReq();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeAnonReq(emitSections));
|
||||
break;
|
||||
case PayloadType.PATH:
|
||||
return this.decodePath();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodePath(emitSections));
|
||||
break;
|
||||
case PayloadType.TRACE:
|
||||
return this.decodeTrace();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeTrace(emitSections));
|
||||
break;
|
||||
case PayloadType.MULTIPART:
|
||||
return this.decodeMultipart();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeMultipart(emitSections));
|
||||
break;
|
||||
case PayloadType.CONTROL:
|
||||
return this.decodeControl();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeControl(emitSections));
|
||||
break;
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
return this.decodeRawCustom();
|
||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeRawCustom(emitSections));
|
||||
break;
|
||||
default:
|
||||
throw new Error(`MeshCore: can't decode payload ${this.payloadType}`)
|
||||
}
|
||||
|
||||
if (!emitSections) {
|
||||
return decodedPayload;
|
||||
}
|
||||
|
||||
private decodeEncrypted<T>(kind: PayloadType): T {
|
||||
const sections: Segment[] = [];
|
||||
if (this._frameworkSection) {
|
||||
sections.push(this._frameworkSection);
|
||||
}
|
||||
if (payloadSections) {
|
||||
sections.push(...payloadSections);
|
||||
}
|
||||
|
||||
return { payload: decodedPayload, sections: sections.length > 0 ? sections : undefined };
|
||||
}
|
||||
|
||||
private decodeEncrypted<T extends { sections?: Segment[] }>(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } {
|
||||
let offset = 0;
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
|
||||
const dstHash = buffer.readUint8().toString(16).padStart(2, '0');
|
||||
const srcHash = buffer.readUint8().toString(16).padStart(2, '0');
|
||||
const cipherMAC = buffer.readBytes(2);
|
||||
const cipherText = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'uint8', name: 'Destination Hash' },
|
||||
{ byteWidth: 1, type: 'uint8', name: 'Source Hash' },
|
||||
{ byteWidth: 2, type: 'uint8', name: 'Cipher MAC' },
|
||||
{ byteWidth: cipherText.length, type: 'uint8', name: 'Cipher Text' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: kind,
|
||||
dstHash: buffer.readUint8().toString(16).padStart(2, '0'),
|
||||
srcHash: buffer.readUint8().toString(16).padStart(2, '0'),
|
||||
cipherMAC: buffer.readBytes(2),
|
||||
cipherText: buffer.readBytes()
|
||||
} as T
|
||||
dstHash,
|
||||
srcHash,
|
||||
cipherMAC,
|
||||
cipherText,
|
||||
} as unknown as T,
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodeRequest(): RequestPayload {
|
||||
return this.decodeEncrypted<RequestPayload>(PayloadType.REQUEST);
|
||||
private decodeRequest(emitSections = false): { payload: RequestPayload; sections?: Segment[] } {
|
||||
return this.decodeEncrypted<RequestPayload>(PayloadType.REQUEST, emitSections);
|
||||
}
|
||||
|
||||
private decodeResponse(): ResponsePayload {
|
||||
return this.decodeEncrypted<ResponsePayload>(PayloadType.RESPONSE);
|
||||
private decodeResponse(emitSections = false): { payload: ResponsePayload; sections?: Segment[] } {
|
||||
return this.decodeEncrypted<ResponsePayload>(PayloadType.RESPONSE, emitSections);
|
||||
}
|
||||
|
||||
private decodeText(): TextPayload {
|
||||
return this.decodeEncrypted<TextPayload>(PayloadType.TEXT);
|
||||
private decodeText(emitSections = false): { payload: TextPayload; sections?: Segment[] } {
|
||||
return this.decodeEncrypted<TextPayload>(PayloadType.TEXT, emitSections);
|
||||
}
|
||||
|
||||
private decodeAck(): AckPayload {
|
||||
return this.decodeEncrypted<AckPayload>(PayloadType.ACK);
|
||||
private decodeAck(emitSections = false): { payload: AckPayload; sections?: Segment[] } {
|
||||
return this.decodeEncrypted<AckPayload>(PayloadType.ACK, emitSections);
|
||||
}
|
||||
|
||||
private decodeAdvert(): AdvertPayload {
|
||||
private decodeAdvert(emitSections = false): { payload: AdvertPayload; sections?: Segment[] } {
|
||||
let offset = 0;
|
||||
const sections: Segment[] = [];
|
||||
const attributes: Attribute[] = [];
|
||||
|
||||
const buffer = new BufferReader(this.payload);
|
||||
const publicKey = buffer.readBytes(32);
|
||||
attributes.push({ byteWidth: 32, type: 'uint8', name: 'Public Key' });
|
||||
|
||||
const timestampValue = buffer.readUint32LE();
|
||||
attributes.push({ byteWidth: 4, type: 'uint32le', name: 'timestamp' });
|
||||
|
||||
const signature = buffer.readBytes(64);
|
||||
let payload: AdvertPayload = {
|
||||
attributes.push({ byteWidth: 64, type: 'uint8', name: 'signature' });
|
||||
|
||||
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 } = {
|
||||
flags,
|
||||
};
|
||||
|
||||
if (flags & AdvertisementFlags.HAS_LOCATION) {
|
||||
const latitude = buffer.readUint32LE() / 1000000.0;
|
||||
const longitude = buffer.readUint32LE() / 1000000.0;
|
||||
appdata.latitude = latitude;
|
||||
appdata.longitude = longitude;
|
||||
attributes.push({ byteWidth: 4, type: 'uint32le', name: 'latitude' });
|
||||
attributes.push({ byteWidth: 4, type: 'uint32le', name: 'longitude' });
|
||||
}
|
||||
if (flags & AdvertisementFlags.HAS_FEATURE_1) {
|
||||
const feature1 = buffer.readUint16LE();
|
||||
appdata.feature1 = feature1;
|
||||
attributes.push({ byteWidth: 2, type: 'uint16le', name: 'Feature 1' });
|
||||
}
|
||||
if (flags & AdvertisementFlags.HAS_FEATURE_2) {
|
||||
const feature2 = buffer.readUint16LE();
|
||||
appdata.feature2 = feature2;
|
||||
attributes.push({ byteWidth: 2, type: 'uint16le', name: 'Feature 2' });
|
||||
}
|
||||
if (flags & AdvertisementFlags.HAS_NAME) {
|
||||
const nameBytes = buffer.readBytes();
|
||||
const name = new TextDecoder().decode(nameBytes).toString();
|
||||
appdata.name = name;
|
||||
attributes.push({ byteWidth: nameBytes.length, type: 'string', name: 'name' });
|
||||
}
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset,
|
||||
byteCount: this.payload.length,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: PayloadType.ADVERT,
|
||||
publicKey: publicKey,
|
||||
publicKey,
|
||||
timestamp: timestampValue === 0 ? undefined : new Date(timestampValue),
|
||||
signature: signature,
|
||||
appdata: {
|
||||
flags: buffer.readUint8(),
|
||||
}
|
||||
}
|
||||
if (payload.appdata.flags & AdvertisementFlags.HAS_LOCATION) {
|
||||
payload.appdata.latitude = buffer.readUint32LE() / 1000000.0
|
||||
payload.appdata.longitude = buffer.readUint32LE() / 1000000.0
|
||||
}
|
||||
if (payload.appdata.flags & AdvertisementFlags.HAS_FEATURE_1) {
|
||||
payload.appdata.feature1 = buffer.readUint16LE()
|
||||
}
|
||||
if (payload.appdata.flags & AdvertisementFlags.HAS_FEATURE_2) {
|
||||
payload.appdata.feature2 = buffer.readUint16LE()
|
||||
}
|
||||
if (payload.appdata.flags & AdvertisementFlags.HAS_NAME) {
|
||||
payload.appdata.name = new TextDecoder().decode(buffer.readBytes()).toString()
|
||||
}
|
||||
return payload;
|
||||
signature,
|
||||
appdata,
|
||||
},
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodeGroupEncrypted<T>(kind: PayloadType): T {
|
||||
private decodeGroupEncrypted<T extends { sections?: Segment[] }>(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } {
|
||||
let offset = 0;
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
|
||||
const channelHash = buffer.readUint8().toString(16).padStart(2, '0');
|
||||
const cipherMAC = buffer.readBytes(2);
|
||||
const cipherText = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'uint8', name: 'Channel Hash' },
|
||||
{ byteWidth: 2, type: 'uint8', name: 'Cipher MAC' },
|
||||
{ byteWidth: cipherText.length, type: 'uint8', name: 'Cipher Text' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: kind,
|
||||
channelHash: buffer.readUint8().toString(16).padStart(2, '0'),
|
||||
cipherMAC: buffer.readBytes(2),
|
||||
cipherText: buffer.readBytes()
|
||||
} as T;
|
||||
channelHash,
|
||||
cipherMAC,
|
||||
cipherText,
|
||||
} as unknown as T,
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodeGroupText(): GroupTextPayload {
|
||||
return this.decodeGroupEncrypted<GroupTextPayload>(PayloadType.GROUP_TEXT);
|
||||
private decodeGroupText(emitSections = false): { payload: GroupTextPayload; sections?: Segment[] } {
|
||||
return this.decodeGroupEncrypted<GroupTextPayload>(PayloadType.GROUP_TEXT, emitSections);
|
||||
}
|
||||
|
||||
private decodeGroupData(): GroupDataPayload {
|
||||
return this.decodeGroupEncrypted<GroupDataPayload>(PayloadType.GROUP_DATA);
|
||||
private decodeGroupData(emitSections = false): { payload: GroupDataPayload; sections?: Segment[] } {
|
||||
return this.decodeGroupEncrypted<GroupDataPayload>(PayloadType.GROUP_DATA, emitSections);
|
||||
}
|
||||
|
||||
private decodeAnonReq(): AnonReqPayload {
|
||||
private decodeAnonReq(emitSections = false): { payload: AnonReqPayload; sections?: Segment[] } {
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
|
||||
const dstHash = buffer.readUint8().toString(16).padStart(2, '0');
|
||||
const publicKey = buffer.readBytes(32);
|
||||
const cipherMAC = buffer.readBytes(2);
|
||||
const cipherText = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset: 0,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'uint8', name: 'Destination Hash' },
|
||||
{ byteWidth: 32, type: 'uint8', name: 'Public Key' },
|
||||
{ byteWidth: 2, type: 'uint8', name: 'Cipher MAC' },
|
||||
{ byteWidth: cipherText.length, type: 'uint8', name: 'Cipher Text' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: PayloadType.ANON_REQ,
|
||||
dstHash: buffer.readUint8().toString(16).padStart(2, '0'),
|
||||
publicKey: buffer.readBytes(32),
|
||||
cipherMAC: buffer.readBytes(2),
|
||||
cipherText: buffer.readBytes()
|
||||
}
|
||||
dstHash,
|
||||
publicKey,
|
||||
cipherMAC,
|
||||
cipherText,
|
||||
},
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodePath(): PathPayload {
|
||||
return this.decodeEncrypted<PathPayload>(PayloadType.PATH);
|
||||
private decodePath(emitSections = false): { payload: PathPayload; sections?: Segment[] } {
|
||||
return this.decodeEncrypted<PathPayload>(PayloadType.PATH, emitSections);
|
||||
}
|
||||
|
||||
private decodeTrace(): TracePayload {
|
||||
private decodeTrace(emitSections = false): { payload: TracePayload; sections?: Segment[] } {
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
const data = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset: 0,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: data.length, type: 'uint8', name: 'data' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: PayloadType.TRACE,
|
||||
data: buffer.readBytes()
|
||||
}
|
||||
data,
|
||||
},
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodeMultipart(): MultipartPayload {
|
||||
private decodeMultipart(emitSections = false): { payload: MultipartPayload; sections?: Segment[] } {
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
const data = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset: 0,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: data.length, type: 'uint8', name: 'data' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: PayloadType.MULTIPART,
|
||||
data: buffer.readBytes()
|
||||
}
|
||||
data,
|
||||
},
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodeControl(): ControlPayload {
|
||||
private decodeControl(emitSections = false): { payload: ControlPayload; sections?: Segment[] } {
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
const flags = buffer.readUint8();
|
||||
const data = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset: 0,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: 1, type: 'uint8', name: 'flags' },
|
||||
{ byteWidth: data.length, type: 'uint8', name: 'data' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: PayloadType.CONTROL,
|
||||
flags: buffer.readUint8(),
|
||||
data: buffer.readBytes()
|
||||
}
|
||||
flags,
|
||||
data,
|
||||
},
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decodeRawCustom(): RawCustomPayload {
|
||||
private decodeRawCustom(emitSections = false): { payload: RawCustomPayload; sections?: Segment[] } {
|
||||
const sections: Segment[] = [];
|
||||
const buffer = new BufferReader(this.payload);
|
||||
return {
|
||||
payloadType: PayloadType.RAW_CUSTOM,
|
||||
data: buffer.readBytes()
|
||||
const data = buffer.readBytes();
|
||||
|
||||
if (emitSections) {
|
||||
sections.push({
|
||||
name: 'Payload',
|
||||
offset: 0,
|
||||
byteCount: this.payload.length,
|
||||
attributes: [
|
||||
{ byteWidth: data.length, type: 'uint8', name: 'data' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
payloadType: PayloadType.RAW_CUSTOM,
|
||||
data,
|
||||
},
|
||||
sections: emitSections ? sections : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIService } from './API';
|
||||
import type { Packet } from '../types/protocol.types';
|
||||
|
||||
export interface FetchedAPRSPacket {
|
||||
export interface FetchedAPRSPacket extends Packet {
|
||||
id?: number;
|
||||
radio_id?: number;
|
||||
radio?: {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { BaseStream } from './Stream';
|
||||
import type { Packet } from '../types/protocol.types';
|
||||
|
||||
export interface APRSMessage {
|
||||
export interface APRSMessage extends Packet {
|
||||
topic: string;
|
||||
receivedAt: Date;
|
||||
raw: string;
|
||||
radioName?: string;
|
||||
}
|
||||
|
||||
interface APRSJsonEnvelope {
|
||||
@@ -17,6 +16,8 @@ interface APRSJsonEnvelope {
|
||||
Time?: string;
|
||||
time?: string;
|
||||
timestamp?: string;
|
||||
snr?: number;
|
||||
rssi?: number;
|
||||
}
|
||||
|
||||
const fromBase64 = (value: string): string => {
|
||||
@@ -62,6 +63,8 @@ export class APRSStream extends BaseStream {
|
||||
receivedAt,
|
||||
raw,
|
||||
radioName,
|
||||
snr: envelope.snr,
|
||||
rssi: envelope.rssi,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIService } from './API';
|
||||
import type { Group } from '../protocols/meshcore.types';
|
||||
import { PayloadType } from '../protocols/meshcore.types';
|
||||
import type { Group } from '../types/protocol/meshcore.types';
|
||||
import type { Packet } from '../types/protocol.types';
|
||||
import { PayloadType } from '../types/protocol/meshcore.types';
|
||||
import { GroupSecret } from '../protocols/meshcore';
|
||||
|
||||
interface FetchedMeshCoreGroup {
|
||||
@@ -15,11 +16,9 @@ export type MeshCoreGroupRecord = Group & {
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export interface FetchedGroupPacket {
|
||||
export interface FetchedMeshCorePacket extends Packet {
|
||||
id: number;
|
||||
radio_id: number;
|
||||
snr: number;
|
||||
rssi: number;
|
||||
version: number;
|
||||
route_type: number;
|
||||
payload_type: number;
|
||||
@@ -57,12 +56,24 @@ export class MeshCoreServiceImpl {
|
||||
/**
|
||||
* Fetch all MeshCore packets
|
||||
* @param limit Maximum number of packets to fetch (default: 200)
|
||||
* @param type Optional payload type to filter by
|
||||
* @param channelHash Optional channel hash to filter by
|
||||
* @returns Array of raw packet data
|
||||
*/
|
||||
public async fetchPackets(limit = 200): Promise<FetchedGroupPacket[]> {
|
||||
public async fetchPackets(
|
||||
limit = 200,
|
||||
type?: number,
|
||||
channelHash?: string,
|
||||
): Promise<FetchedMeshCorePacket[]> {
|
||||
const endpoint = '/meshcore/packets';
|
||||
const params = { limit };
|
||||
return this.api.fetch<FetchedGroupPacket[]>(endpoint, { params });
|
||||
const params: Record<string, unknown> = { limit };
|
||||
if (type !== undefined) {
|
||||
params.type = type;
|
||||
}
|
||||
if (channelHash !== undefined) {
|
||||
params.channel_hash = channelHash;
|
||||
}
|
||||
return this.api.fetch<FetchedMeshCorePacket[]>(endpoint, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,13 +81,8 @@ export class MeshCoreServiceImpl {
|
||||
* @param channelHash The channel hash to fetch packets for
|
||||
* @returns Array of raw packet data
|
||||
*/
|
||||
public async fetchGroupPackets(channelHash: string): Promise<FetchedGroupPacket[]> {
|
||||
const endpoint = '/meshcore/packets';
|
||||
const params = {
|
||||
type: PayloadType.GROUP_TEXT,
|
||||
channel_hash: channelHash,
|
||||
};
|
||||
return this.api.fetch<FetchedGroupPacket[]>(endpoint, { params });
|
||||
public async fetchGroupPackets(channelHash: string): Promise<FetchedMeshCorePacket[]> {
|
||||
return this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,31 @@
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import { Packet } from '../protocols/meshcore';
|
||||
import type { Payload } from '../protocols/meshcore.types';
|
||||
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 { BaseStream } from './Stream';
|
||||
|
||||
export interface MeshCoreMessage {
|
||||
export interface MeshCoreMessage extends Packet {
|
||||
topic: string;
|
||||
receivedAt: Date;
|
||||
raw: Uint8Array;
|
||||
hash: string;
|
||||
decodedPayload?: Payload;
|
||||
radioName?: string;
|
||||
}
|
||||
|
||||
interface MeshCoreJsonEnvelope {
|
||||
payloadBase64?: string;
|
||||
payloadHex?: string;
|
||||
snr?: number;
|
||||
rssi?: number;
|
||||
}
|
||||
|
||||
const hexToBytes = (hex: string): Uint8Array => {
|
||||
const normalized = hex.trim().toLowerCase();
|
||||
if (normalized.length % 2 !== 0) {
|
||||
throw new Error('MeshCoreStream: invalid hex payload length');
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(normalized.length / 2);
|
||||
for (let i = 0; i < normalized.length; i += 2) {
|
||||
bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export class MeshCoreStream extends BaseStream {
|
||||
constructor(autoConnect = false) {
|
||||
super({}, autoConnect);
|
||||
}
|
||||
|
||||
protected decodeMessage(topic: string, payload: Uint8Array): MeshCoreMessage {
|
||||
const packetBytes = this.extractPacketBytes(payload);
|
||||
const parsed = new Packet();
|
||||
const { bytes: packetBytes, snr, rssi } = this.extractPacketBytes(payload);
|
||||
const parsed = new MeshCorePacket();
|
||||
parsed.parse(packetBytes);
|
||||
|
||||
let decodedPayload: Payload | undefined;
|
||||
@@ -58,7 +46,9 @@ export class MeshCoreStream extends BaseStream {
|
||||
raw: packetBytes,
|
||||
hash: bytesToHex(parsed.hash()),
|
||||
decodedPayload,
|
||||
radioName
|
||||
radioName,
|
||||
snr,
|
||||
rssi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,21 +67,25 @@ export class MeshCoreStream extends BaseStream {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractPacketBytes(payload: Uint8Array): Uint8Array {
|
||||
private extractPacketBytes(payload: Uint8Array): { bytes: Uint8Array; snr?: number; rssi?: number } {
|
||||
const text = new TextDecoder().decode(payload).trim();
|
||||
if (!text.startsWith('{')) {
|
||||
return payload;
|
||||
return { bytes: payload };
|
||||
}
|
||||
|
||||
const envelope = JSON.parse(text) as MeshCoreJsonEnvelope;
|
||||
let bytes: Uint8Array = payload;
|
||||
|
||||
if (envelope.payloadBase64) {
|
||||
return Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0));
|
||||
bytes = Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0));
|
||||
} else if (envelope.payloadHex) {
|
||||
bytes = hexToBytes(envelope.payloadHex);
|
||||
}
|
||||
|
||||
if (envelope.payloadHex) {
|
||||
return hexToBytes(envelope.payloadHex);
|
||||
}
|
||||
|
||||
return payload;
|
||||
return {
|
||||
bytes,
|
||||
snr: envelope.snr,
|
||||
rssi: envelope.rssi,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
45
ui/src/types/protocol.types.ts
Normal file
45
ui/src/types/protocol.types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const protocols = {
|
||||
adsb: 'ADSB',
|
||||
aprs: 'APRS',
|
||||
ax25: 'AX.25',
|
||||
meshcore: 'MeshCore',
|
||||
meshtastic: 'Meshtastic',
|
||||
} as const;
|
||||
|
||||
export type Protocol = keyof typeof protocols;
|
||||
|
||||
export const modulations = {
|
||||
am: 'AM',
|
||||
cw: 'CW (Morse)',
|
||||
fm: 'FM',
|
||||
lora: 'LoRA',
|
||||
lsb: 'LSB',
|
||||
usb: 'USB',
|
||||
} as const;
|
||||
|
||||
export type Modulation = keyof typeof modulations;
|
||||
|
||||
export const toProtocolDisplayName = (protocol: string): string => {
|
||||
const normalized = protocol.toLowerCase() as Protocol;
|
||||
return protocols[normalized] ?? protocol;
|
||||
};
|
||||
|
||||
export const toModulationDisplayName = (modulation: string): string => {
|
||||
const normalized = modulation.toLowerCase() as Modulation;
|
||||
return modulations[normalized] ?? modulation.toUpperCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Base packet interface with common fields from all protocol types.
|
||||
* These fields are typically extracted from Go's protocol.Packet struct.
|
||||
*/
|
||||
export interface Packet {
|
||||
/** When the packet was received */
|
||||
receivedAt: Date;
|
||||
/** Signal-to-Noise Ratio in dB */
|
||||
snr?: number;
|
||||
/** Received Signal Strength Indicator in dBm */
|
||||
rssi?: number;
|
||||
/** Name/ID of the radio that received the packet */
|
||||
radioName?: string;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Segment } from './dissection.types';
|
||||
|
||||
export interface Address {
|
||||
call: string;
|
||||
ssid: string;
|
||||
@@ -96,6 +98,7 @@ export interface PositionPayload {
|
||||
messageType?: string;
|
||||
isStandard?: boolean;
|
||||
};
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
// Compressed Position Format
|
||||
@@ -305,4 +308,5 @@ export type DecodedPayload =
|
||||
// Extended Frame with decoded payload
|
||||
export interface DecodedFrame extends Frame {
|
||||
decoded?: DecodedPayload;
|
||||
sections?: Segment[]; // Routing and other frame-level sections
|
||||
}
|
||||
51
ui/src/types/protocol/dissection.types.ts
Normal file
51
ui/src/types/protocol/dissection.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Generic packet dissection types for hierarchical protocol analysis.
|
||||
* These types are protocol-agnostic and can be used for APRS, MeshCore, or any other protocol.
|
||||
*/
|
||||
|
||||
export interface Bitfield {
|
||||
offset: number; // Bit offset within the section
|
||||
length: number; // Length in bits
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type Type =
|
||||
| 'uint8'
|
||||
| 'int8'
|
||||
| 'uint16le'
|
||||
| 'uint16be'
|
||||
| 'int16le'
|
||||
| 'int16be'
|
||||
| 'uint32le'
|
||||
| 'uint32be'
|
||||
| 'int32le'
|
||||
| 'int32be'
|
||||
| 'uint64le'
|
||||
| 'uint64be'
|
||||
| 'int64le'
|
||||
| 'int64be'
|
||||
| 'float32le'
|
||||
| 'float32be'
|
||||
| 'float64le'
|
||||
| 'float64be'
|
||||
| 'string'
|
||||
| 'char'
|
||||
| 'bytes'
|
||||
| 'base91'
|
||||
| 'base91number';
|
||||
|
||||
export interface Attribute {
|
||||
byteWidth: number; // Width in bytes
|
||||
type: Type;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
name: string;
|
||||
offset: number; // Byte offset in the original packet
|
||||
byteCount: number; // Number of bytes in this segment
|
||||
stringOnly?: boolean; // Render full section as plain string
|
||||
bitfields?: Bitfield[];
|
||||
attributes?: Attribute[];
|
||||
children?: Segment[]; // For nested segments (e.g., source/destination within routing)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Segment } from './dissection.types';
|
||||
|
||||
export type NodeHash = string; // first byte of the hash
|
||||
|
||||
export const RouteType = {
|
||||
@@ -35,6 +37,7 @@ export interface Packet {
|
||||
payloadType: PayloadType;
|
||||
path: Uint8Array;
|
||||
payload: Uint8Array;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export abstract class BasePacket {
|
||||
@@ -100,6 +103,7 @@ export interface RequestPayload extends EncryptedPayload {
|
||||
readonly payloadType: typeof PayloadType.REQUEST;
|
||||
dstHash: NodeHash;
|
||||
srcHash: NodeHash;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface DecryptedRequest {
|
||||
@@ -136,6 +140,7 @@ export interface ResponsePayload extends EncryptedPayload {
|
||||
readonly payloadType: typeof PayloadType.RESPONSE;
|
||||
dstHash: NodeHash;
|
||||
srcHash: NodeHash;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface DecryptedResponse {
|
||||
@@ -155,6 +160,7 @@ export interface TextPayload extends EncryptedPayload {
|
||||
readonly payloadType: typeof PayloadType.TEXT;
|
||||
dstHash: NodeHash;
|
||||
srcHash: NodeHash;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface DecryptedTextMessage {
|
||||
@@ -167,9 +173,11 @@ export interface SignedTextMessage extends DecryptedTextMessage {
|
||||
senderPubkeyPrefix: Uint8Array; // First 4 bytes of sender pubkey (when txt_type = 0x02)
|
||||
}
|
||||
|
||||
export interface AckPayload {
|
||||
export interface AckPayload extends EncryptedPayload {
|
||||
readonly payloadType: typeof PayloadType.ACK;
|
||||
checksum: number; // 4 bytes, LE - CRC of message timestamp, text, and sender pubkey
|
||||
dstHash: NodeHash;
|
||||
srcHash: NodeHash;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export const AdvertisementFlags = {
|
||||
@@ -200,6 +208,7 @@ export interface AdvertPayload {
|
||||
timestamp?: Date; // Unix timestamp (4 bytes, LE), undefined if timestamp is 0
|
||||
signature: Uint8Array; // 64 bytes Ed25519 signature
|
||||
appdata: AdvertisementAppData;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface EncryptedGroupPayload extends EncryptedPayload {
|
||||
@@ -208,10 +217,12 @@ export interface EncryptedGroupPayload extends EncryptedPayload {
|
||||
|
||||
export interface GroupTextPayload extends EncryptedGroupPayload {
|
||||
readonly payloadType: typeof PayloadType.GROUP_TEXT;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface GroupDataPayload extends EncryptedGroupPayload {
|
||||
readonly payloadType: typeof PayloadType.GROUP_DATA;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface DecryptedGroupMessage {
|
||||
@@ -225,6 +236,7 @@ export interface AnonReqPayload extends EncryptedPayload {
|
||||
readonly payloadType: typeof PayloadType.ANON_REQ;
|
||||
dstHash: NodeHash; // first byte of destination node public key
|
||||
publicKey: Uint8Array; // 32 bytes - sender's ephemeral Ed25519 public key
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export const AnonRequestSubType = {
|
||||
@@ -273,6 +285,7 @@ export interface PathPayload extends EncryptedPayload {
|
||||
readonly payloadType: typeof PayloadType.PATH;
|
||||
dstHash: NodeHash;
|
||||
srcHash: NodeHash;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface DecryptedPath {
|
||||
@@ -286,11 +299,13 @@ export interface TracePayload {
|
||||
readonly payloadType: typeof PayloadType.TRACE;
|
||||
// Format not fully specified in docs - collecting SNI for each hop
|
||||
data: Uint8Array;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface MultipartPayload {
|
||||
readonly payloadType: typeof PayloadType.MULTIPART;
|
||||
data: Uint8Array; // One part of a multi-part packet
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export const ControlSubType = {
|
||||
@@ -314,6 +329,7 @@ export interface ControlPayload {
|
||||
readonly payloadType: typeof PayloadType.CONTROL;
|
||||
flags: number; // Upper 4 bits is sub_type
|
||||
data: Uint8Array;
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface DiscoverRequest {
|
||||
@@ -335,6 +351,7 @@ export interface DiscoverResponse {
|
||||
export interface RawCustomPayload {
|
||||
readonly payloadType: typeof PayloadType.RAW_CUSTOM;
|
||||
data: Uint8Array; // Raw bytes for custom encryption/application
|
||||
sections?: Segment[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Modulation, Protocol } from './protocol.types';
|
||||
|
||||
export interface Radio {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -7,8 +9,8 @@ export interface Radio {
|
||||
firmware_version: string | null;
|
||||
firmware_date: string | null;
|
||||
antenna: string | null;
|
||||
modulation: string;
|
||||
protocol: string;
|
||||
modulation: Modulation;
|
||||
protocol: Protocol;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
altitude?: number;
|
||||
|
||||
Reference in New Issue
Block a user