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