Added SNR and refactored types
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 37s
Test and build / Build receiver (push) Failing after 37s

This commit is contained in:
2026-03-06 09:06:08 +01:00
parent e83df1c143
commit 247c827291
27 changed files with 2533 additions and 615 deletions

View File

@@ -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".

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

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

View File

@@ -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) && (
<>

View File

@@ -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));
}
}

View File

@@ -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>
)
}

View File

@@ -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,
});
};

View File

@@ -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>
);
};

View File

@@ -12,6 +12,8 @@ export interface MeshCorePacketRecord {
decodedPayload?: unknown;
payloadSummary: string;
radioName?: string;
snr?: number;
rssi?: number;
}
export interface MeshCoreGroupChatRecord {

View File

@@ -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

View File

@@ -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 WireDissector: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const [bitArtMode, setBitArtMode] = useState<'compact' | 'verbose'>('compact');
const { rows, pathHashSize, pathHashCount, pathBytesAvailable, payloadOffset } = useMemo(
() => buildByteDissection(packet),
[packet]
);
const headerByte = packet.raw[0] ?? 0;
const pathByte = packet.raw[1] ?? 0;
const pathBytes = packet.raw.slice(2, payloadOffset);
const payloadBytes = packet.raw.slice(payloadOffset);
const hexdumpRows = useMemo(() => {
const chunks: ByteDissectionRow[][] = [];
for (let i = 0; i < rows.length; i += 16) {
chunks.push(rows.slice(i, i + 16));
}
return chunks.map((chunk, rowIndex) => ({
offset: rowIndex * 16,
cells: [...chunk, ...Array<ByteDissectionRow | null>(Math.max(16 - chunk.length, 0)).fill(null)],
}));
}, [rows]);
return (
<Card body className="data-table-card">
<Stack direction="horizontal" className="justify-content-between align-items-center mb-2">
<h6 className="mb-0">Packet Bytes (Wire View)</h6>
<button
type="button"
className="meshcore-bitart-toggle"
onClick={() => setBitArtMode((prev) => (prev === 'compact' ? 'verbose' : 'compact'))}
title="Toggle compact/verbose bit pointer annotations"
>
Bit Art: {bitArtMode === 'compact' ? 'Compact' : 'Verbose'}
</button>
</Stack>
<div className="meshcore-wire-subtitle">Protocol tree + hex dump, similar to a packet analyzer output</div>
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)`,
},
],
});
}
<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>
return segments;
};
<div className="meshcore-ws-panel">
<div className="meshcore-ws-panel-title">Hex Dump</div>
<div className="meshcore-ws-legend mb-2">
<span><i className="meshcore-ws-chip meshcore-ws-zone-header" />Header</span>
<span><i className="meshcore-ws-chip meshcore-ws-zone-path" />Path</span>
<span><i className="meshcore-ws-chip meshcore-ws-zone-payload" />Payload</span>
</div>
<div className="meshcore-ws-hexdump">
{hexdumpRows.map((row) => (
<div key={row.offset} className="meshcore-ws-hexdump-row">
<span className="meshcore-ws-offset">{row.offset.toString(16).padStart(4, '0')}</span>
<span className="meshcore-ws-bytes">
{row.cells.map((cell, index) => (
<span key={`${row.offset}-${index}`} className={`meshcore-ws-byte ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
{cell ? cell.byte.toString(16).padStart(2, '0') : ' '}
</span>
))}
</span>
<span className="meshcore-ws-ascii">
{row.cells.map((cell, index) => (
<span key={`${row.offset}-ascii-${index}`} className={`meshcore-ws-char ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
{cell ? toAscii(cell.byte) : ' '}
</span>
))}
</span>
</div>
))}
</div>
</div>
</div>
</Card>
);
const asRecord = (value: unknown): Record<string, unknown> | null => {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
return null;
};
const PayloadDetails: React.FC<{ packet: 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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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);
}

View File

@@ -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();
});
});
});

View File

@@ -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,
};
}
}

View File

@@ -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?: {

View File

@@ -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,
};
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

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

View File

@@ -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
}

View 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)
}

View File

@@ -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 {

View File

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