diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 9792f40..bbaa582 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -62,6 +62,11 @@ Run `npm run build` and run `pre-commit run --files changed files...` Never add secrets to code. +## Modifying code + +Prefer the patching strategy over running shell commands where possible. +Prevent using temporary files and shell commands where possible. + ## Addressing Don't call me "the user", refer to me as "the developer". diff --git a/ui/src/components/PacketDissectionViewer.scss b/ui/src/components/PacketDissectionViewer.scss new file mode 100644 index 0000000..60c2586 --- /dev/null +++ b/ui/src/components/PacketDissectionViewer.scss @@ -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; +} diff --git a/ui/src/components/PacketDissectionViewer.tsx b/ui/src/components/PacketDissectionViewer.tsx new file mode 100644 index 0000000..a1c41ec --- /dev/null +++ b/ui/src/components/PacketDissectionViewer.tsx @@ -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 = ({ + rawPacket, + segments, + title = 'Packet Dissection', +}) => { + const bytes = useMemo(() => toBytes(rawPacket), [rawPacket]); + const colors = useMemo(() => generateColorPalette(segments.length), [segments.length]); + const sectionRefs = useRef>(new Map()); + const [hoveredSegment, setHoveredSegment] = useState(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 }> = []; + + const alignedStart = Math.floor(baseOffset / 8) * 8; + const endExclusive = baseOffset + data.length; + + for (let rowOffset = alignedStart; rowOffset < endExclusive; rowOffset += 8) { + const cells: Array = []; + 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 ( +
+ {rows.map((row) => ( +
+ + {row.offset.toString(16).padStart(offsetHexWidth, '0')} + + + {row.cells.map((cell, index) => ( + + {cell !== null ? cell.toString(16).padStart(2, '0') : ' '} + + ))} + + + {row.cells.map((cell, index) => ( + + {cell !== null ? toAscii(cell) : ' '} + + ))} + +
+ ))} +
+ ); + }; + + if (bytes.length === 0) { + return ( +
+
No packet data available
+
+ ); + } + + return ( +
+
{title}
+ + {/* Hexdump Section */} +
+
Hex Dump
+
+ {segments.map((segment, index) => ( + + + {segment.name} + + ))} +
+
+ {hexdumpRows.map((row) => ( +
+ + {row.offset.toString(16).padStart(offsetHexWidth, '0')} + + + {row.cells.map((cell, index) => ( + + ))} + + + {row.cells.map((cell, index) => ( + = 0 ? 'packet-dissection-char-active' : 'packet-dissection-char-empty' + }`} + style={ + cell && cell.segmentIndex >= 0 + ? { backgroundColor: colors[cell.segmentIndex] } + : undefined + } + > + {cell ? toAscii(cell.value) : ' '} + + ))} + +
+ ))} +
+
+ + {/* Segments Section */} +
+ {segments.map((segment, segmentIndex) => { + const segmentBytes = bytes.slice(segment.offset, segment.offset + segment.byteCount); + + return ( +
{ + 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)} + > +
+ {segment.name} + + Offset: {segment.offset} (0x{segment.offset.toString(16)}), Length: {segment.byteCount} bytes + +
+ +
+ {/* Raw bytes / string */} + {segment.stringOnly ? ( +
+ String: + {new TextDecoder().decode(segmentBytes)} +
+ ) : ( +
+ Bytes: + {renderInlineHexdump(segmentBytes, segment.offset, colors[segmentIndex])} +
+ )} + + {/* Bitfields */} + {segment.bitfields && segment.bitfields.length > 0 && ( +
+
Bitfields:
+ {/* Group bitfields by byte */} + {(() => { + const byteGroups = new Map(); + 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 ( +
+
+                              {'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('')}
+                            
+
+ ); + }); + })()} +
+ )} + + {/* Attributes */} + {segment.attributes && segment.attributes.length > 0 && ( +
+
Attributes:
+
    + {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 ( +
  • + {attr.name} + ({attr.type}) + = {displayValue} +
  • + ); + })} +
+
+ )} + + {/* Nested children */} + {segment.children && segment.children.length > 0 && ( +
+
Nested Segments:
+ {segment.children.map((child) => { + const childBytes = bytes.slice(child.offset, child.offset + child.byteCount); + + return ( +
+
{child.name}
+
+ {child.stringOnly ? ( +
+ String: + {new TextDecoder().decode(childBytes)} +
+ ) : ( +
+ Bytes: + {renderInlineHexdump(childBytes, child.offset, colors[segmentIndex])} +
+ )} + + {child.attributes && child.attributes.length > 0 && ( +
    + {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 ( +
  • + {attr.name} + ({attr.type}) + = {displayValue} +
  • + ); + })} +
+ )} +
+
+ ); + })} +
+ )} +
+
+ ); + })} +
+
+ ); +}; + +export default PacketDissectionViewer; diff --git a/ui/src/components/RadioCard.tsx b/ui/src/components/RadioCard.tsx index 738b38f..6acb970 100644 --- a/ui/src/components/RadioCard.tsx +++ b/ui/src/components/RadioCard.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Card } from 'react-bootstrap'; +import { useNavigate } from 'react-router'; import type { Radio } from '../types/radio.types'; import { getDeviceImageURL } from '../libs/deviceImageMapper'; +import { toModulationDisplayName, toProtocolDisplayName } from '../types/protocol.types'; import './RadioCard.scss'; interface RadioCardProps { @@ -9,10 +11,17 @@ interface RadioCardProps { } export const RadioCard: React.FC = ({ radio }) => { + const navigate = useNavigate(); const deviceImageURL = getDeviceImageURL(radio.protocol, radio.manufacturer, radio.device); + const modulationName = toModulationDisplayName(radio.modulation); + const protocolName = toProtocolDisplayName(radio.protocol); + + const handleClick = () => { + navigate(`/${radio.protocol}/packets?radios=${encodeURIComponent(radio.name)}`); + }; return ( - +
= ({ radio }) => {
Modulation: - {radio.modulation} + {modulationName}
Protocol: - {radio.protocol} + {protocolName}
{(radio.lora_sf !== undefined || radio.lora_cr !== undefined) && ( <> diff --git a/ui/src/pages/Overview.scss b/ui/src/pages/Overview.scss index a9e566d..b528643 100644 --- a/ui/src/pages/Overview.scss +++ b/ui/src/pages/Overview.scss @@ -1,5 +1,5 @@ .overview-container { - padding: 24px; + padding: 8px; height: 100%; overflow-y: auto; } @@ -8,6 +8,17 @@ margin-bottom: 32px; } +.overview-category { + margin-bottom: 24px; +} + +.overview-category-title { + font-size: 18px; + font-weight: 600; + color: var(--app-text); + margin-bottom: 12px; +} + .overview-title { font-size: 24px; font-weight: 700; @@ -40,11 +51,29 @@ color: #fca5a5; } -.overview-radios-row { +.overview-radios-rows { + display: flex; + flex-direction: column; gap: 16px; } -.overview-radio-col { - display: flex; - margin-bottom: 16px; +.overview-radios-row { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.overview-radio-item { + display: flex; + + .radio-card { + width: 100%; + min-width: 0; + } +} + +@media (min-width: 992px) { + .overview-radios-row { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } } diff --git a/ui/src/pages/Overview.tsx b/ui/src/pages/Overview.tsx index ed09a32..cc2c258 100644 --- a/ui/src/pages/Overview.tsx +++ b/ui/src/pages/Overview.tsx @@ -1,15 +1,105 @@ import type React from "react"; import Layout from "../components/Layout"; +import RadioCard from "../components/RadioCard"; +import { useRadios } from "../contexts/RadiosContext"; import type { NavLinkItem } from "../types/layout.types"; +import type { Radio } from "../types/radio.types"; +import './Overview.scss'; interface Props { navLinks?: NavLinkItem[] } +const MAX_RADIOS_PER_ROW = 6; + +const chunkRadios = (radios: Radio[], chunkSize: number): Radio[][] => { + const chunks: Radio[][] = []; + for (let i = 0; i < radios.length; i += chunkSize) { + chunks.push(radios.slice(i, i + chunkSize)); + } + return chunks; +}; + +const compareRadio = (left: Radio, right: Radio): number => { + const protocolCompare = left.protocol.localeCompare(right.protocol, undefined, { sensitivity: 'base' }); + if (protocolCompare !== 0) { + return protocolCompare; + } + + return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }); +}; + export const Overview: React.FC = ({ 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 ( - Hi mom! +
+
+

Radios ({totalRadiosCount})

+ + {loading &&
Loading radios…
} + + {!loading && error &&
{error}
} + + {!loading && !error && radios.length === 0 && ( +
No radios found.
+ )} + + {!loading && !error && radios.length > 0 && ( + <> +
+

Online ({onlineRadiosCount})

+ {onlineRows.length === 0 ? ( +
No online radios.
+ ) : ( +
+ {onlineRows.map((row, rowIndex) => ( +
+ {row.map((radio) => ( +
+ +
+ ))} +
+ ))} +
+ )} +
+ +
+

Offline ({offlineRadiosCount})

+ {offlineRows.length === 0 ? ( +
No offline radios.
+ ) : ( +
+ {offlineRows.map((row, rowIndex) => ( +
+ {row.map((radio) => ( +
+ +
+ ))} +
+ ))} +
+ )} +
+ + )} +
+
) } diff --git a/ui/src/pages/aprs/APRSData.tsx b/ui/src/pages/aprs/APRSData.tsx index c128e3a..fc7178d 100644 --- a/ui/src/pages/aprs/APRSData.tsx +++ b/ui/src/pages/aprs/APRSData.tsx @@ -18,6 +18,8 @@ export interface APRSPacketRecord { symbol?: string; radioName?: string; hasAPILocation?: boolean; + snr?: number; + rssi?: number; } interface APRSDataContextValue { @@ -173,6 +175,8 @@ const buildRecord = ({ longitude, symbol, preferProvidedLocation, + snr, + rssi, }: { raw: string; timestamp: Date; @@ -182,6 +186,8 @@ const buildRecord = ({ longitude?: number; symbol?: string; preferProvidedLocation?: boolean; + snr?: number; + rssi?: number; }): APRSPacketRecord | null => { try { const frame = parseFrame(raw); @@ -207,6 +213,8 @@ const buildRecord = ({ symbol: symbol ?? details.symbol, radioName, hasAPILocation: preferProvidedLocation && hasProvidedLocation, + snr, + rssi, }; } catch { return null; @@ -228,6 +236,8 @@ const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null => longitude: fromNullableNumber(packet.longitude), symbol: packet.symbol, preferProvidedLocation: true, + snr: packet.snr, + rssi: packet.rssi, }); }; @@ -236,6 +246,8 @@ const toRecordFromStream = (message: APRSMessage): APRSPacketRecord | null => { raw: message.raw, timestamp: message.receivedAt, radioName: message.radioName, + snr: message.snr, + rssi: message.rssi, }); }; diff --git a/ui/src/pages/aprs/APRSPacketsView.tsx b/ui/src/pages/aprs/APRSPacketsView.tsx index 9c35894..da4fb7c 100644 --- a/ui/src/pages/aprs/APRSPacketsView.tsx +++ b/ui/src/pages/aprs/APRSPacketsView.tsx @@ -1,28 +1,23 @@ import React, { useMemo, useState } from 'react'; -import { Badge, Card, Stack, Table } from 'react-bootstrap'; +import { Card, Table } from 'react-bootstrap'; import { MapContainer, TileLayer, Popup, useMap, CircleMarker, Marker } from 'react-leaflet'; import { divIcon, type DivIcon } from 'leaflet'; import { renderToStaticMarkup } from 'react-dom/server'; import { useSearchParams } from 'react-router'; import VerticalSplit from '../../components/VerticalSplit'; import HorizontalSplit from '../../components/HorizontalSplit'; +import PacketDissectionViewer from '../../components/PacketDissectionViewer'; import CountryFlag from '../../components/CountryFlag'; import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal'; import StreamStatus from '../../components/StreamStatus'; import { APRSSymbol } from '../../components/aprs'; import { ClusteredMarkers } from '../../components/map'; import type { ClusterableItem, Cluster } from '../../components/map'; +import type { Segment } from '../../types/protocol/dissection.types'; import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation'; import { useAPRSData } from './APRSData'; import type { APRSPacketRecord } from './APRSData'; -const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( -
- {label} - {value} -
-); - const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => ( {call} @@ -345,6 +340,112 @@ const APRSMapPane: React.FC<{ ); }; +const shiftSegment = (segment: Segment, delta: number): Segment => ({ + ...segment, + offset: segment.offset + delta, + children: segment.children?.map((child) => shiftSegment(child, delta)), +}); + +const buildFallbackAPRSSegments = (packet: APRSPacketRecord): Segment[] => { + const raw = packet.raw; + const gtIndex = raw.indexOf('>'); + const colonIndex = raw.indexOf(':'); + + if (gtIndex < 0 || colonIndex < 0 || colonIndex <= gtIndex) { + return [ + { + name: 'raw_packet', + offset: 0, + byteCount: raw.length, + attributes: [{ byteWidth: raw.length, type: 'string', name: 'packet' }], + }, + ]; + } + + const sections: Segment[] = []; + const children: Segment[] = []; + + const sourcePart = raw.substring(0, gtIndex); + children.push({ + name: 'source', + offset: 0, + byteCount: sourcePart.length, + attributes: [{ byteWidth: sourcePart.length, type: 'string', name: 'callsign' }], + }); + + const headerPart = raw.substring(gtIndex + 1, colonIndex); + const headerParts = headerPart.split(','); + const destinationPart = headerParts[0] ?? ''; + const destinationOffset = gtIndex + 1; + children.push({ + name: 'destination', + offset: destinationOffset, + byteCount: destinationPart.length, + attributes: [{ byteWidth: destinationPart.length, type: 'string', name: 'callsign' }], + }); + + let pathOffset = destinationOffset + destinationPart.length; + for (let i = 1; i < headerParts.length; i += 1) { + const repeater = headerParts[i] ?? ''; + pathOffset += 1; + children.push({ + name: `repeater_${i - 1}`, + offset: pathOffset, + byteCount: repeater.length, + attributes: [{ byteWidth: repeater.length, type: 'string', name: 'callsign' }], + }); + pathOffset += repeater.length; + } + + sections.push({ + name: 'routing', + offset: 0, + byteCount: colonIndex, + children, + }); + + const payloadOffset = colonIndex + 1; + if (payloadOffset < raw.length) { + const payloadLength = raw.length - payloadOffset; + sections.push({ + name: 'payload', + offset: payloadOffset, + byteCount: payloadLength, + children: [ + { + name: 'data_type', + offset: payloadOffset, + byteCount: 1, + attributes: [{ byteWidth: 1, type: 'char', name: 'identifier' }], + }, + ], + }); + } + + return sections; +}; + +const buildAPRSSegments = (packet: APRSPacketRecord): Segment[] => { + const payloadStart = packet.raw.indexOf(':') + 1; + + try { + const decoded = packet.frame.decode(true); + const sections = decoded.sections ?? []; + if (sections.length === 0) { + return buildFallbackAPRSSegments(packet); + } + + return sections.map((section) => { + if (section.name === 'Routing' || section.name === 'Data Type') { + return section; + } + return shiftSegment(section, payloadStart); + }); + } catch { + return buildFallbackAPRSSegments(packet); + } +}; + const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { if (!packet) { return ( @@ -356,50 +457,13 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack } return ( - - - -
Packet Details
- - - -
- - } - /> - {packet.radioName && } - } - /> - `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} /> -
- - {(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && ( - -
Position Data
- {packet.latitude && } - {packet.longitude && } - {packet.altitude && } - {packet.speed !== undefined && } - {packet.course !== undefined && } -
- )} - - {packet.comment && ( - -
Comment
-
{packet.comment}
-
- )} - - -
Raw Data
- {packet.raw} -
-
+ + + ); }; diff --git a/ui/src/pages/meshcore/MeshCoreContext.tsx b/ui/src/pages/meshcore/MeshCoreContext.tsx index 1cf9095..121d42f 100644 --- a/ui/src/pages/meshcore/MeshCoreContext.tsx +++ b/ui/src/pages/meshcore/MeshCoreContext.tsx @@ -12,6 +12,8 @@ export interface MeshCorePacketRecord { decodedPayload?: unknown; payloadSummary: string; radioName?: string; + snr?: number; + rssi?: number; } export interface MeshCoreGroupChatRecord { diff --git a/ui/src/pages/meshcore/MeshCoreData.tsx b/ui/src/pages/meshcore/MeshCoreData.tsx index 786f3d1..0652a58 100644 --- a/ui/src/pages/meshcore/MeshCoreData.tsx +++ b/ui/src/pages/meshcore/MeshCoreData.tsx @@ -12,10 +12,10 @@ import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt'; import StorageIcon from '@mui/icons-material/Storage'; import { Packet } from '../../protocols/meshcore'; -import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types'; +import { NodeType, PayloadType, RouteType } from '../../types/protocol/meshcore.types'; import { MeshCoreStream } from '../../services/MeshCoreStream'; import type { MeshCoreMessage } from '../../services/MeshCoreStream'; -import type { Payload, AdvertPayload } from '../../protocols/meshcore.types'; +import type { Payload, AdvertPayload } from '../../types/protocol/meshcore.types'; import API from '../../services/API'; import MeshCoreServiceImpl from '../../services/MeshCoreService'; import { base64ToBytes } from '../../util'; @@ -37,17 +37,36 @@ export { type MeshCoreNodePoint, } from './MeshCoreContext'; -export const payloadNameByValue = Object.fromEntries( - Object.entries(PayloadType).map(([name, value]) => [value, name]) -) as Record; +export const payloadNameByValue: Record = { + [PayloadType.REQUEST]: 'Request', + [PayloadType.RESPONSE]: 'Response', + [PayloadType.TEXT]: 'Private Message', + [PayloadType.ACK]: 'Acknowledgement', + [PayloadType.ADVERT]: 'Advertisement', + [PayloadType.GROUP_TEXT]: 'Group Message', + [PayloadType.GROUP_DATA]: 'Group Data', + [PayloadType.ANON_REQ]: 'Anonymous Request', + [PayloadType.PATH]: 'Path', + [PayloadType.TRACE]: 'Trace', + [PayloadType.MULTIPART]: 'Multipart', + [PayloadType.CONTROL]: 'Control', + [PayloadType.RAW_CUSTOM]: 'Custom', +}; -export const nodeTypeNameByValue = Object.fromEntries( - Object.entries(NodeType).map(([name, value]) => [value, name]) -) as Record; +export const nodeTypeNameByValue: Record = { + [NodeType.TYPE_UNKNOWN]: 'Unknown', + [NodeType.TYPE_CHAT_NODE]: 'Chat Node', + [NodeType.TYPE_REPEATER]: 'Repeater', + [NodeType.TYPE_ROOM_SERVER]: 'Room Server', + [NodeType.TYPE_SENSOR]: 'Sensor', +}; -export const routeTypeNameByValue = Object.fromEntries( - Object.entries(RouteType).map(([name, value]) => [value, name]) -) as Record; +export const routeTypeNameByValue: Record = { + [RouteType.TRANSPORT_FLOOD]: 'Transport Flood', + [RouteType.FLOOD]: 'Flood', + [RouteType.DIRECT]: 'Direct', + [RouteType.TRANSPORT_DIRECT]: 'Transport Direct', +}; export const payloadValueByName = Object.fromEntries( Object.entries(PayloadType).map(([name, value]) => [name, value]) @@ -56,31 +75,31 @@ export const payloadValueByName = Object.fromEntries( export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType }) => { switch (payloadType) { case PayloadType.REQUEST: - return ; + return ; case PayloadType.RESPONSE: - return ; + return ; case PayloadType.TEXT: - return ; + return ; case PayloadType.ACK: - return ; + return ; case PayloadType.ADVERT: - return ; + return ; case PayloadType.GROUP_TEXT: - return ; + return ; case PayloadType.GROUP_DATA: - return ; + return ; case PayloadType.ANON_REQ: - return ; + return ; case PayloadType.PATH: - return ; + return ; case PayloadType.TRACE: - return ; + return ; case PayloadType.MULTIPART: - return ; + return ; case PayloadType.CONTROL: - return ; + return ; case PayloadType.RAW_CUSTOM: - return ; + return ; default: return ; } @@ -282,6 +301,10 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => { const byNode = new Map(); packets.forEach((packet) => { + if (!packet.path || packet.path.length === 0) { + return; + } + const nodeId = packet.path[0].toString(16).padStart(2, '0'); const existing = byNode.get(nodeId); @@ -346,6 +369,8 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ raw, decodedPayload, payloadSummary: summarizePayload(packet.payload_type, decodedPayload, payloadBytes), + snr: packet.snr, + rssi: packet.rssi, }; }); @@ -383,6 +408,8 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ decodedPayload: message.decodedPayload, payloadSummary: '', radioName: message.radioName, + snr: message.snr, + rssi: message.rssi, }; // Extract details from raw packet diff --git a/ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx b/ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx index 9fd3244..687e109 100644 --- a/ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx +++ b/ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx @@ -1,11 +1,14 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import { bytesToHex } from '@noble/hashes/utils.js'; import { Badge, Card, Stack } from 'react-bootstrap'; +import PacketDissectionViewer from '../../components/PacketDissectionViewer'; +import type { Segment } from '../../protocols/dissection.types'; + import type { MeshCorePacketRecord } from './MeshCoreContext'; import { - payloadDisplayByValue, + payloadNameByValue, routeDisplayByValue, } from './MeshCoreData'; @@ -16,95 +19,13 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label ); -const toBitString = (value: number): string => value.toString(2).padStart(8, '0'); - -const byteHex = (value: number | undefined): string => { - if (typeof value !== 'number') { - return '0x??'; - } - return `0x${value.toString(16).padStart(2, '0')}`; -}; - const bitSlice = (value: number, msb: number, lsb: number): number => { const width = msb - lsb + 1; const mask = (1 << width) - 1; return (value >> lsb) & mask; }; -const toAscii = (value: number): string => { - if (value >= 32 && value <= 126) { - return String.fromCharCode(value); - } - return '.'; -}; - -const asRecord = (value: unknown): Record | null => { - if (value && typeof value === 'object') { - return value as Record; - } - return null; -}; - -interface BitFieldSpec { - msb: number; - lsb: number; - shortLabel: string; - label: string; - value: number; -} - -const buildBitPointerLine = (msb: number, lsb: number): string => { - const width = 15; - const start = (7 - msb) * 2; - const end = (7 - lsb) * 2; - const chars = Array.from({ length: width }, () => ' '); - - if (start === end) { - chars[start] = '↑'; - return chars.join(''); - } - - chars[start] = '└'; - for (let i = start + 1; i < end; i += 1) { - chars[i] = '─'; - } - chars[end] = '┘'; - return chars.join(''); -}; - -const renderBitPointerArt = ( - value: number, - fields: BitFieldSpec[], - mode: 'compact' | 'verbose' -): string => { - const header = 'bits: 7 6 5 4 3 2 1 0'; - const bitRow = `val : ${toBitString(value).split('').join(' ')}`; - const pointers = fields.map((field) => { - const name = mode === 'compact' ? field.shortLabel : field.label; - return ` ${buildBitPointerLine(field.msb, field.lsb)} ${name} = ${field.value}`; - }); - - if (mode === 'compact') { - const legend = `key : ${fields.map((field) => field.shortLabel).join(', ')}`; - return [header, bitRow, ...pointers, legend].join('\n'); - } - - return [header, bitRow, ...pointers].join('\n'); -}; - -interface ByteDissectionRow { - index: number; - byte: number; - zone: 'header' | 'path' | 'payload'; -} - -const buildByteDissection = (packet: MeshCorePacketRecord): { - rows: ByteDissectionRow[]; - pathHashSize: number; - pathHashCount: number; - pathBytesAvailable: number; - payloadOffset: number; -} => { +const buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => { const pathField = packet.raw.length > 1 ? packet.raw[1] : 0; const pathHashSize = bitSlice(pathField, 7, 6) + 1; const pathHashCount = bitSlice(pathField, 5, 0); @@ -112,194 +33,87 @@ const buildByteDissection = (packet: MeshCorePacketRecord): { const pathBytesAvailable = Math.min(pathBytesExpected, Math.max(packet.raw.length - 2, 0)); const payloadOffset = 2 + pathBytesAvailable; - const rows: ByteDissectionRow[] = Array.from(packet.raw).map((byte, index) => { - if (index <= 1) { - return { - index, - byte, - zone: 'header', - }; - } + const segments: Segment[] = [ + { + name: 'Header', + offset: 0, + byteCount: 1, + bitfields: [ + { + offset: 6, + length: 2, + name: 'Version', + }, + { + offset: 2, + length: 4, + name: `Payload Type (${payloadNameByValue[packet.payloadType] ?? 'Unknown'})`, + }, + { + offset: 0, + length: 2, + name: `Route Type (${routeDisplayByValue[packet.routeType] ?? 'Unknown'})`, + }, + ], + }, + { + name: 'Path Descriptor', + offset: 1, + byteCount: 1, + bitfields: [ + { + offset: 6, + length: 2, + name: `Hash Size Selector (size=${pathHashSize})`, + }, + { + offset: 0, + length: 6, + name: `Hash Count (count=${pathHashCount})`, + }, + ], + }, + ]; - if (index < payloadOffset) { - return { - index, - byte, - zone: 'path', - }; - } + if (pathBytesAvailable > 0) { + segments.push({ + name: 'Path Data', + offset: 2, + byteCount: pathBytesAvailable, + attributes: [ + { + byteWidth: pathBytesAvailable, + type: 'bytes', + name: `Path Hashes (${pathHashCount} × ${pathHashSize} bytes)`, + }, + ], + }); + } - return { - index, - byte, - zone: 'payload', - }; - }); + const payloadLength = packet.raw.length - payloadOffset; + if (payloadLength > 0) { + segments.push({ + name: 'Payload', + offset: payloadOffset, + byteCount: payloadLength, + attributes: [ + { + byteWidth: payloadLength, + type: 'bytes', + name: `Payload Data (${payloadLength} bytes)`, + }, + ], + }); + } - return { - rows, - pathHashSize, - pathHashCount, - pathBytesAvailable, - payloadOffset, - }; + return segments; }; -const WireDissector: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => { - const [bitArtMode, setBitArtMode] = useState<'compact' | 'verbose'>('compact'); - - const { rows, pathHashSize, pathHashCount, pathBytesAvailable, payloadOffset } = useMemo( - () => buildByteDissection(packet), - [packet] - ); - - const headerByte = packet.raw[0] ?? 0; - const pathByte = packet.raw[1] ?? 0; - const pathBytes = packet.raw.slice(2, payloadOffset); - const payloadBytes = packet.raw.slice(payloadOffset); - - const hexdumpRows = useMemo(() => { - const chunks: ByteDissectionRow[][] = []; - for (let i = 0; i < rows.length; i += 16) { - chunks.push(rows.slice(i, i + 16)); - } - return chunks.map((chunk, rowIndex) => ({ - offset: rowIndex * 16, - cells: [...chunk, ...Array(Math.max(16 - chunk.length, 0)).fill(null)], - })); - }, [rows]); - - return ( - - -
Packet Bytes (Wire View)
- -
-
Protocol tree + hex dump, similar to a packet analyzer output
- -
-
-
Packet Details
-
    -
  • - Frame 1: {packet.raw.length} bytes on wire ({packet.raw.length * 8} bits) -
  • -
  • - MeshCore Header -
      -
    • Byte[0] = {byteHex(headerByte)} = {toBitString(headerByte)}
    • -
    • b7..b6: Version = {bitSlice(headerByte, 7, 6)}
    • -
    • b5..b2: Payload Type = {bitSlice(headerByte, 5, 2)} ({payloadDisplayByValue[packet.payloadType] ?? 'Unknown'})
    • -
    • b1..b0: Route Type = {bitSlice(headerByte, 1, 0)} ({routeDisplayByValue[packet.routeType] ?? 'Unknown'})
    • -
    • -
      -                    {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)}
      -                  
      -
    • -
    -
  • -
  • - Path Descriptor -
      -
    • Byte[1] = {byteHex(pathByte)} = {toBitString(pathByte)}
    • -
    • b7..b6: Hash size = ({bitSlice(pathByte, 7, 6)} + 1) = {pathHashSize}
    • -
    • b5..b0: Hash count = {pathHashCount}
    • -
    • Path bytes in frame = {pathBytesAvailable}
    • -
    • Path data = {pathBytes.length > 0 ? bytesToHex(pathBytes) : 'none'}
    • -
    • -
      -                    {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)}
      -                  
      -
    • -
    -
  • -
  • - Payload -
      -
    • Payload offset = {payloadOffset}
    • -
    • Payload length = {payloadBytes.length} bytes
    • -
    • Payload bytes = {payloadBytes.length > 0 ? bytesToHex(payloadBytes) : 'none'}
    • -
    -
  • -
-
- -
-
Hex Dump
-
- Header - Path - Payload -
-
- {hexdumpRows.map((row) => ( -
- {row.offset.toString(16).padStart(4, '0')} - - {row.cells.map((cell, index) => ( - - {cell ? cell.byte.toString(16).padStart(2, '0') : ' '} - - ))} - - - {row.cells.map((cell, index) => ( - - {cell ? toAscii(cell.byte) : ' '} - - ))} - -
- ))} -
-
-
-
- ); +const asRecord = (value: unknown): Record | null => { + if (value && typeof value === 'object') { + return value as Record; + } + return null; }; const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => { @@ -397,20 +211,27 @@ const MeshCorePacketDetailsPane: React.FC = ({ p -
Packet Header
- {payloadDisplayByValue[packet.payloadType] ?? packet.payloadType} +
{payloadNameByValue[packet.payloadType] ?? packet.payloadType}
{packet.hash}} /> + {packet.snr !== undefined && } + {packet.rssi !== undefined && } {packet.radioName && } - + {bytesToHex(packet.path)}} /> {bytesToHex(packet.raw)}} />
- + + +
Stream Preparation
diff --git a/ui/src/pages/meshcore/MeshCorePacketFilters.tsx b/ui/src/pages/meshcore/MeshCorePacketFilters.tsx index 3a5e1e3..4c97d3f 100644 --- a/ui/src/pages/meshcore/MeshCorePacketFilters.tsx +++ b/ui/src/pages/meshcore/MeshCorePacketFilters.tsx @@ -8,7 +8,7 @@ import { } from 'react-bootstrap'; import { - payloadDisplayByValue, + payloadNameByValue, PayloadTypeIcon, routeDisplayByValue, } from './MeshCoreData'; @@ -166,7 +166,7 @@ const MeshCorePacketFilters: React.FC = ({ label="Payload Type" options={uniquePayloadTypes} selectedValues={filterPayloadTypes} - getLabelForValue={(value) => payloadDisplayByValue[value] ?? `0x${value.toString(16)}`} + getLabelForValue={(value) => payloadNameByValue[value] ?? `0x${value.toString(16)}`} getIconForValue={(value) => } onToggle={onPayloadToggle} onSelectAll={onPayloadSelectAll} diff --git a/ui/src/pages/meshcore/MeshCorePacketRows.tsx b/ui/src/pages/meshcore/MeshCorePacketRows.tsx index 3b4b0f4..e0c3042 100644 --- a/ui/src/pages/meshcore/MeshCorePacketRows.tsx +++ b/ui/src/pages/meshcore/MeshCorePacketRows.tsx @@ -4,10 +4,10 @@ import { bytesToHex } from '@noble/hashes/utils.js'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { PayloadType } from '../../protocols/meshcore.types'; +import { PayloadType } from '../../types/protocol/meshcore.types'; import type { MeshCorePacketRecord } from './MeshCoreContext'; import { - payloadDisplayByValue, + payloadNameByValue, PayloadTypeIcon, } from './MeshCoreData'; @@ -131,12 +131,13 @@ const MeshCorePacketRows: React.FC = ({
)} + {packet.snr !== undefined ? packet.snr.toFixed(1) : '-'} dB - + {packet.payloadSummary} @@ -154,9 +155,10 @@ const MeshCorePacketRows: React.FC = ({ {duplicatePacket.hash} - + + {duplicatePacket.snr !== undefined ? duplicatePacket.snr.toFixed(1) : '-'} dB {getPathInfo(duplicatePacket).prefixes} diff --git a/ui/src/pages/meshcore/MeshCorePacketTable.tsx b/ui/src/pages/meshcore/MeshCorePacketTable.tsx index 29181e3..641026c 100644 --- a/ui/src/pages/meshcore/MeshCorePacketTable.tsx +++ b/ui/src/pages/meshcore/MeshCorePacketTable.tsx @@ -214,6 +214,7 @@ const MeshCorePacketTable: React.FC = ({ packets, sele Time + SNR Hash Type Info diff --git a/ui/src/protocols/aprs.test.ts b/ui/src/protocols/aprs.test.ts index 886d460..e0682ef 100644 --- a/ui/src/protocols/aprs.test.ts +++ b/ui/src/protocols/aprs.test.ts @@ -282,6 +282,19 @@ it('should call decode method and return position data', () => { } }); + it('should decode uncompressed position with alternate symbol table (\\)', () => { + const data = String.raw`W1AW>APRS:!4903.50N\07201.75W-Test message`; + const frame = parseFrame(data); + const decoded = frame.decode(); + + expect(decoded).not.toBeNull(); + expect(decoded?.type).toBe('position'); + + if (decoded && decoded.type === 'position') { + expect(decoded.position.symbol.table).toBe('\\'); + expect(decoded.position.comment).toBe('Test message'); + } + }); it('should decode position with messaging capability', () => { const data = 'W1AW>APRS:=4903.50N/07201.75W-'; const frame = parseFrame(data); @@ -428,6 +441,47 @@ it('should call decode method and return position data', () => { } }); }); + + describe('Object decoding', () => { + it('should decode object payload with uncompressed position', () => { + const data = 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object'; + const frame = parseFrame(data); + const decoded = frame.decode(); + + expect(decoded).not.toBeNull(); + expect(decoded?.type).toBe('object'); + + if (decoded && decoded.type === 'object') { + expect(decoded.name).toBe('OBJECT'); + expect(decoded.alive).toBe(true); + expect(decoded.timestamp).toBeDefined(); + expect(decoded.position).toBeDefined(); + expect(typeof decoded.position.latitude).toBe('number'); + expect(typeof decoded.position.longitude).toBe('number'); + expect(decoded.position.comment).toBe('Test object'); + } + }); + + it('should emit object sections when emitSections is true', () => { + const data = 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object'; + const frame = parseFrame(data); + const result = frame.decode(true); + + expect(result.payload).not.toBeNull(); + expect(result.payload?.type).toBe('object'); + expect(result.sections.length).toBeGreaterThan(0); + + const nameSection = result.sections.find((s) => s.name === 'Object Name'); + const stateSection = result.sections.find((s) => s.name === 'Object State'); + const timestampSection = result.sections.find((s) => s.name === 'timestamp'); + const positionSection = result.sections.find((s) => s.name === 'position'); + + expect(nameSection).toBeDefined(); + expect(stateSection).toBeDefined(); + expect(timestampSection).toBeDefined(); + expect(positionSection).toBeDefined(); + }); + }); }); describe('Timestamp class', () => { @@ -962,3 +1016,110 @@ describe('Mic-E decoding', () => { }); }); }); + +describe('Packet dissection with sections', () => { + it('should emit routing sections when emitSections is true', () => { + const data = 'KB1ABC-5>APRS,WIDE1-1,WIDE2-2*:!4903.50N/07201.75W-Test'; + const frame = parseFrame(data); + const result = frame.decode(true); + + expect(result).toHaveProperty('payload'); + expect(result).toHaveProperty('sections'); + expect(result.sections).toBeDefined(); + expect(result.sections.length).toBeGreaterThan(0); + + // Check routing section (capital R as implemented) + const routingSection = result.sections.find(s => s.name === 'Routing'); + expect(routingSection).toBeDefined(); + expect(routingSection?.attributes).toBeDefined(); + expect(routingSection?.attributes?.length).toBeGreaterThan(0); + + // Check for source and destination attributes + const sourceAttr = routingSection?.attributes?.find(a => a.name === 'Source address'); + expect(sourceAttr).toBeDefined(); + expect(sourceAttr?.byteWidth).toBeGreaterThan(0); + + const destAttr = routingSection?.attributes?.find(a => a.name === 'Destination address'); + expect(destAttr).toBeDefined(); + expect(destAttr?.byteWidth).toBeGreaterThan(0); + }); + + it('should emit position payload sections when emitSections is true', () => { + const data = 'CALL>APRS:!4903.50N/07201.75W-Test message'; + const frame = parseFrame(data); + const result = frame.decode(true); + + expect(result.payload).not.toBeNull(); + expect(result.payload?.type).toBe('position'); + + // Check if result has sections at top level + expect(result.sections).toBeDefined(); + expect(result.sections?.length).toBeGreaterThan(0); + + // Find position section + const positionSection = result.sections?.find(s => s.name === 'position'); + expect(positionSection).toBeDefined(); + expect(positionSection?.byteCount).toBe(19); // Uncompressed position is 19 bytes + expect(positionSection?.attributes).toBeDefined(); + expect(positionSection?.attributes?.length).toBeGreaterThan(0); + }); + + it('should not emit sections when emitSections is false or omitted', () => { + const data = 'CALL>APRS:!4903.50N/07201.75W-Test'; + const frame = parseFrame(data); + const result = frame.decode(); + + // Result should be just the DecodedPayload, not an object with payload and sections + expect(result).not.toBeNull(); + expect(result?.type).toBe('position'); + expect((result as any).sections).toBeUndefined(); + }); + + it('should emit timestamp section when present', () => { + const data = 'CALL>APRS:@092345z4903.50N/07201.75W>'; + const frame = parseFrame(data); + const result = frame.decode(true); + + expect(result.payload?.type).toBe('position'); + + const timestampSection = result.sections?.find(s => s.name === 'timestamp'); + expect(timestampSection).toBeDefined(); + expect(timestampSection?.byteCount).toBe(7); + expect(timestampSection?.offset).toBe(1); // After the '@' data type identifier + expect(timestampSection?.attributes?.map(a => a.name)).toEqual([ + 'Day (DD)', + 'Hour (HH)', + 'Minute (MM)', + 'Timezone Indicator', + ]); + }); + + it('should emit compressed position sections', () => { + const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; + const frame = parseFrame(data); + const result = frame.decode(true); + + expect(result.payload?.type).toBe('position'); + + const positionSection = result.sections?.find(s => s.name === 'position'); + expect(positionSection).toBeDefined(); + expect(positionSection?.byteCount).toBe(13); // Compressed position is 13 bytes + + // Check for base91 encoded attributes + const latAttr = positionSection?.attributes?.find(a => a.name === 'Latitude'); + expect(latAttr).toBeDefined(); + expect(latAttr?.type).toBe('base91'); + }); + + it('should emit comment section', () => { + const data = 'CALL>APRS:!4903.50N/07201.75W-Test message'; + const frame = parseFrame(data); + const result = frame.decode(true); + + expect(result.payload?.type).toBe('position'); + const commentSection = result.sections?.find(s => s.name === 'comment'); + expect(commentSection).toBeDefined(); + expect(commentSection?.byteCount).toBe('Test message'.length); + expect(commentSection?.attributes?.[0]?.name).toBe('text'); + }); +}); diff --git a/ui/src/protocols/aprs.ts b/ui/src/protocols/aprs.ts index acf5067..f75331d 100644 --- a/ui/src/protocols/aprs.ts +++ b/ui/src/protocols/aprs.ts @@ -1,4 +1,5 @@ -import type { Address, Frame as IFrame, DecodedPayload, Timestamp as ITimestamp } from "./aprs.types" +import type { Address, Frame as IFrame, DecodedPayload, Timestamp as ITimestamp } from "../types/protocol/aprs.types" +import type { Attribute, Segment } from "../types/protocol/dissection.types" import { base91ToNumber } from "../libs/base91" export class Timestamp implements ITimestamp { @@ -117,12 +118,14 @@ export class Frame implements IFrame { destination: Address; path: Address[]; payload: string; + private _routingSection?: Segment; - constructor(source: Address, destination: Address, path: Address[], payload: string) { + constructor(source: Address, destination: Address, path: Address[], payload: string, routingSection?: Segment) { this.source = source; this.destination = destination; this.path = path; this.payload = payload; + this._routingSection = routingSection; } /** @@ -133,14 +136,45 @@ export class Frame implements IFrame { } /** - * Decode the APRS payload based on its data type identifier + * Get or build routing section from cached data */ - decode(): DecodedPayload | null { + private getRoutingSection(): Segment | undefined { + return this._routingSection; + } + + /** + * Decode the APRS payload based on its data type identifier + * Returns the decoded payload with optional sections for packet dissection + */ + decode(): DecodedPayload | null; + decode(emitSections: true): { payload: DecodedPayload | null; sections: Segment[] }; + decode(emitSections?: boolean): DecodedPayload | null | { payload: DecodedPayload | null; sections: Segment[] } { if (!this.payload) { + if (emitSections) { + const sections: Segment[] = []; + const routingSection = this.getRoutingSection(); + if (routingSection) { + sections.push(routingSection); + + // Add data type identifier section + sections.push({ + name: 'Data Type', + offset: routingSection ? routingSection.byteCount : 0, + byteCount: 1, + attributes: [ + { byteWidth: 1, type: 'char', name: 'Identifier' }, + ], + }); + + } + return { payload: null, sections }; + } return null; } const dataType = this.getDataTypeIdentifier(); + let decodedPayload: DecodedPayload | null = null; + let payloadSections: Segment[] | undefined = undefined; // TODO: Implement full decoding logic for each payload type switch (dataType) { @@ -148,69 +182,107 @@ export class Frame implements IFrame { case '=': // Position without timestamp, with messaging case '/': // Position with timestamp, no messaging case '@': // Position with timestamp, with messaging - return this.decodePosition(dataType); + ({ payload: decodedPayload, sections: payloadSections } = this.decodePosition(dataType, emitSections)); + break; case '`': // Mic-E current case "'": // Mic-E old - return this.decodeMicE(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeMicE(emitSections)); + break; case ':': // Message - return this.decodeMessage(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeMessage(emitSections)); + break; case ';': // Object - return this.decodeObject(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeObject(emitSections)); + break; case ')': // Item - return this.decodeItem(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeItem(emitSections)); + break; case '>': // Status - return this.decodeStatus(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeStatus(emitSections)); + break; case '?': // Query - return this.decodeQuery(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeQuery(emitSections)); + break; case 'T': // Telemetry - return this.decodeTelemetry(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeTelemetry(emitSections)); + break; case '_': // Weather without position - return this.decodeWeather(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeWeather(emitSections)); + break; case '$': // Raw GPS - return this.decodeRawGPS(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeRawGPS(emitSections)); + break; case '<': // Station capabilities - return this.decodeCapabilities(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeCapabilities(emitSections)); + break; case '{': // User-defined - return this.decodeUserDefined(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeUserDefined(emitSections)); + break; case '}': // Third-party - return this.decodeThirdParty(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeThirdParty(emitSections)); + break; default: - return null; + decodedPayload = null; } + + 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 { const hasTimestamp = dataType === '/' || dataType === '@'; const messaging = dataType === '=' || dataType === '@'; let offset = 1; // Skip data type identifier + // Build sections as we parse + const sections: Segment[] = emitSections ? [] : []; + let timestamp: Timestamp | undefined = undefined; - // Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHHMM) + // Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM) if (hasTimestamp) { - if (this.payload.length < 8) return null; + if (this.payload.length < 8) return { payload: null }; + const timestampOffset = offset; const timeStr = this.payload.substring(offset, offset + 7); - timestamp = this.parseTimestamp(timeStr); + const { timestamp: parsedTimestamp, section: timestampSection } = this.parseTimestamp(timeStr, emitSections, timestampOffset); + timestamp = parsedTimestamp; + + if (timestampSection) { + sections.push(timestampSection); + } + offset += 7; } - if (this.payload.length < offset + 19) return null; + if (this.payload.length < offset + 19) return { payload: null }; // Check if compressed format + const positionOffset = offset; const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); let position: any; @@ -218,8 +290,8 @@ export class Frame implements IFrame { if (isCompressed) { // Compressed format: /YYYYXXXX$csT - const compressed = this.parseCompressedPosition(this.payload.substring(offset)); - if (!compressed) return null; + const { position: compressed, section: compressedSection } = this.parseCompressedPosition(this.payload.substring(offset), emitSections, positionOffset); + if (!compressed) return { payload: null }; position = { latitude: compressed.latitude, @@ -231,12 +303,16 @@ export class Frame implements IFrame { position.altitude = compressed.altitude; } + if (compressedSection) { + sections.push(compressedSection); + } + offset += 13; // Compressed position is 13 chars comment = this.payload.substring(offset); } else { // Uncompressed format: DDMMmmH/DDDMMmmH$ - const uncompressed = this.parseUncompressedPosition(this.payload.substring(offset)); - if (!uncompressed) return null; + const { position: uncompressed, section: uncompressedSection } = this.parseUncompressedPosition(this.payload.substring(offset), emitSections, positionOffset); + if (!uncompressed) return { payload: null }; position = { latitude: uncompressed.latitude, @@ -248,6 +324,10 @@ export class Frame implements IFrame { position.ambiguity = uncompressed.ambiguity; } + if (uncompressedSection) { + sections.push(uncompressedSection); + } + offset += 19; // Uncompressed position is 19 chars comment = this.payload.substring(offset); } @@ -260,27 +340,45 @@ export class Frame implements IFrame { if (comment) { position.comment = comment; + + // Emit comment section as we parse + if (emitSections) { + sections.push({ + name: 'comment', + offset: offset, + byteCount: comment.length, + attributes: [ + { byteWidth: comment.length, type: 'string', name: 'text' }, + ], + }); + } } - return { + const payload: any = { type: 'position', timestamp, position, messaging, }; + + if (emitSections) { + return { payload, sections }; + } + + return { payload }; } catch (e) { - return null; + return { payload: null }; } } - private parseTimestamp(timeStr: string): Timestamp | undefined { - if (timeStr.length !== 7) return undefined; + private parseTimestamp(timeStr: string, emitSections: boolean = false, offset: number = 0): { timestamp: Timestamp | undefined; section?: Segment } { + if (timeStr.length !== 7) return { timestamp: undefined }; const timeType = timeStr.charAt(6); if (timeType === 'z') { // DHM format: Day-Hour-Minute (UTC) - return new Timestamp( + const timestamp = new Timestamp( parseInt(timeStr.substring(2, 4), 10), parseInt(timeStr.substring(4, 6), 10), 'DHM', @@ -289,9 +387,23 @@ export class Frame implements IFrame { zulu: true, } ); + + const section = emitSections ? { + name: 'timestamp', + offset, + byteCount: 7, + attributes: [ + { byteWidth: 2, type: 'string' as const, name: 'Day (DD)' }, + { byteWidth: 2, type: 'string' as const, name: 'Hour (HH)' }, + { byteWidth: 2, type: 'string' as const, name: 'Minute (MM)' }, + { byteWidth: 1, type: 'char' as const, name: 'Timezone Indicator' }, + ], + } : undefined; + + return { timestamp, section }; } else if (timeType === 'h') { // HMS format: Hour-Minute-Second (UTC) - return new Timestamp( + const timestamp = new Timestamp( parseInt(timeStr.substring(0, 2), 10), parseInt(timeStr.substring(2, 4), 10), 'HMS', @@ -300,9 +412,23 @@ export class Frame implements IFrame { zulu: true, } ); + + const section = emitSections ? { + name: 'timestamp', + offset, + byteCount: 7, + attributes: [ + { byteWidth: 2, type: 'string' as const, name: 'Hour (HH)' }, + { byteWidth: 2, type: 'string' as const, name: 'Minute (MM)' }, + { byteWidth: 2, type: 'string' as const, name: 'Second (SS)' }, + { byteWidth: 1, type: 'char' as const, name: 'Timezone Indicator' }, + ], + } : undefined; + + return { timestamp, section }; } else if (timeType === '/') { // MDHM format: Month-Day-Hour-Minute (local) - return new Timestamp( + const timestamp = new Timestamp( parseInt(timeStr.substring(4, 6), 10), parseInt(timeStr.substring(6, 8), 10), 'MDHM', @@ -312,18 +438,37 @@ export class Frame implements IFrame { zulu: false, } ); + + const section = emitSections ? { + name: 'timestamp', + offset, + byteCount: 7, + attributes: [ + { byteWidth: 2, type: 'string' as const, name: 'Month (MM)' }, + { byteWidth: 2, type: 'string' as const, name: 'Day (DD)' }, + { byteWidth: 2, type: 'string' as const, name: 'Hour (HH)' }, + { byteWidth: 2, type: 'string' as const, name: 'Minute (MM)' }, + { byteWidth: 1, type: 'char' as const, name: 'Timezone Indicator' }, + ], + } : undefined; + + return { timestamp, section }; } - return undefined; + return { timestamp: undefined }; } private isCompressedPosition(data: string): boolean { if (data.length < 13) return false; - // Uncompressed format has / at position 8 (symbol table separator) - // Format: DDMMmmH/DDDMMmmH$ where / is at position 8 - if (data.length >= 19 && data.charAt(8) === '/') { - return false; // It's uncompressed + // First prefer uncompressed detection by attempting an uncompressed parse. + // Uncompressed APRS positions do not have a fixed symbol table separator; + // position 8 is a symbol table identifier and may vary. + if (data.length >= 19) { + const uncompressed = this.parseUncompressedPosition(data, false, 0); + if (uncompressed.position) { + return false; + } } // For compressed format, check if the position part looks like base-91 encoded data @@ -340,8 +485,8 @@ export class Frame implements IFrame { lon2 >= 33 && lon2 <= 124; } - private parseCompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; altitude?: number } | null { - if (data.length < 13) return null; + private parseCompressedPosition(data: string, emitSections: boolean = false, offset: number = 0): { position: { latitude: number; longitude: number; symbol: any; altitude?: number } | null; section?: Segment } { + if (data.length < 13) return { position: null }; const symbolTable = data.charAt(0); const symbolCode = data.charAt(9); @@ -378,14 +523,29 @@ export class Frame implements IFrame { result.altitude = altFeet * 0.3048; // Convert to meters } - return result; + const section = emitSections ? { + name: 'position', + offset, + byteCount: 13, + attributes: [ + { byteWidth: 1, type: 'char' as const, name: 'Symbol Table' }, + { byteWidth: 4, type: 'base91' as const, name: 'Latitude' }, + { byteWidth: 4, type: 'base91' as const, name: 'Longitude' }, + { byteWidth: 1, type: 'char' as const, name: 'Symbol Code' }, + { byteWidth: 1, type: 'char' as const, name: 'Course/Speed Type' }, + { byteWidth: 1, type: 'char' as const, name: 'Course/Speed Value' }, + { byteWidth: 1, type: 'char' as const, name: 'Altitude' }, + ], + } : undefined; + + return { position: result, section }; } catch (e) { - return null; + return { position: null }; } } - private parseUncompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null { - if (data.length < 19) return null; + private parseUncompressedPosition(data: string, emitSections: boolean = false, offset: number = 0): { position: { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null; section?: Segment } { + if (data.length < 19) return { position: null }; // Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code // Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code) @@ -415,8 +575,8 @@ export class Frame implements IFrame { const latMin = parseFloat(latStrNormalized.substring(2, 7)); const latHem = latStrNormalized.charAt(7); - if (isNaN(latDeg) || isNaN(latMin)) return null; - if (latHem !== 'N' && latHem !== 'S') return null; + if (isNaN(latDeg) || isNaN(latMin)) return { position: null }; + if (latHem !== 'N' && latHem !== 'S') return { position: null }; let latitude = latDeg + (latMin / 60); if (latHem === 'S') latitude = -latitude; @@ -426,8 +586,8 @@ export class Frame implements IFrame { const lonMin = parseFloat(lonStrNormalized.substring(3, 8)); const lonHem = lonStrNormalized.charAt(8); - if (isNaN(lonDeg) || isNaN(lonMin)) return null; - if (lonHem !== 'E' && lonHem !== 'W') return null; + if (isNaN(lonDeg) || isNaN(lonMin)) return { position: null }; + if (lonHem !== 'E' && lonHem !== 'W') return { position: null }; let longitude = lonDeg + (lonMin / 60); if (lonHem === 'W') longitude = -longitude; @@ -445,20 +605,35 @@ export class Frame implements IFrame { result.ambiguity = ambiguity; } - return result; + const section = emitSections ? { + name: 'position', + offset, + byteCount: 19, + attributes: [ + { byteWidth: 8, type: 'string' as const, name: 'Latitude' }, + { byteWidth: 1, type: 'char' as const, name: 'Symbol Table' }, + { byteWidth: 9, type: 'string' as const, name: 'Longitude' }, + { byteWidth: 1, type: 'char' as const, name: 'Symbol Code' }, + ], + } : undefined; + + return { position: result, section }; } - private decodeMicE(): DecodedPayload | null { + private decodeMicE(emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { try { + // TODO: Add section emission support when emitSections is true + // For now, Mic-E returns payload without sections + // Mic-E encodes position in both destination address and information field const dest = this.destination.call; - if (dest.length < 6) return null; - if (this.payload.length < 9) return null; // Need at least data type + 8 bytes + if (dest.length < 6) return { payload: null }; + if (this.payload.length < 9) return { payload: null }; // Need at least data type + 8 bytes // Decode latitude from destination address (6 characters) const latResult = this.decodeMicELatitude(dest); - if (!latResult) return null; + if (!latResult) return { payload: null }; const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult; @@ -503,7 +678,7 @@ export class Frame implements IFrame { const speedKmh = speed * 1.852; // Symbol code and table - if (this.payload.length < offset + 2) return null; + if (this.payload.length < offset + 2) return { payload: null }; const symbolCode = this.payload.charAt(offset); const symbolTable = this.payload.charAt(offset + 1); offset += 2; @@ -565,9 +740,9 @@ export class Frame implements IFrame { result.position.comment = comment; } - return result; + return { payload: result }; } catch (e) { - return null; + return { payload: null }; } } @@ -666,59 +841,182 @@ export class Frame implements IFrame { }; } - private decodeMessage(): DecodedPayload | null { - // TODO: Implement message decoding - return null; + private decodeMessage(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement message decoding with section emission + // When implemented, build sections during parsing like decodePosition does + return { payload: null }; } - private decodeObject(): DecodedPayload | null { - // TODO: Implement object decoding - return null; + private decodeObject(emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + try { + // Object format: ;AAAAAAAAAcDDHHMMzDDMM.hhN/DDDMM.hhW$comment + // ^ data type + // 9-char name + // alive (*) / killed (_) + if (this.payload.length < 18) return { payload: null }; // 1 + 9 + 1 + 7 minimum + + let offset = 1; // Skip data type identifier ';' + const sections: Segment[] = emitSections ? [] : []; + + const rawName = this.payload.substring(offset, offset + 9); + const name = rawName.trimEnd(); + if (emitSections) { + sections.push({ + name: 'Object Name', + offset, + byteCount: 9, + attributes: [ + { byteWidth: 9, type: 'string', name: 'Name' }, + ], + stringOnly: true, + }); + } + offset += 9; + + const stateChar = this.payload.charAt(offset); + if (stateChar !== '*' && stateChar !== '_') { + return { payload: null }; + } + const alive = stateChar === '*'; + if (emitSections) { + sections.push({ + name: 'Object State', + offset, + byteCount: 1, + attributes: [ + { byteWidth: 1, type: 'char', name: 'State (* alive, _ killed)' }, + ], + }); + } + offset += 1; + + const timeStr = this.payload.substring(offset, offset + 7); + const { timestamp, section: timestampSection } = this.parseTimestamp(timeStr, emitSections, offset); + if (!timestamp) { + return { payload: null }; + } + if (timestampSection) { + sections.push(timestampSection); + } + offset += 7; + + const positionOffset = offset; + const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); + + let position: { latitude: number; longitude: number; symbol: any; ambiguity?: number; altitude?: number; comment?: string } | null = null; + let consumed = 0; + + if (isCompressed) { + const { position: compressed, section: compressedSection } = this.parseCompressedPosition(this.payload.substring(offset), emitSections, positionOffset); + if (!compressed) return { payload: null }; + + position = { + latitude: compressed.latitude, + longitude: compressed.longitude, + symbol: compressed.symbol, + altitude: compressed.altitude, + }; + consumed = 13; + + if (compressedSection) { + sections.push(compressedSection); + } + } else { + const { position: uncompressed, section: uncompressedSection } = this.parseUncompressedPosition(this.payload.substring(offset), emitSections, positionOffset); + if (!uncompressed) return { payload: null }; + + position = { + latitude: uncompressed.latitude, + longitude: uncompressed.longitude, + symbol: uncompressed.symbol, + ambiguity: uncompressed.ambiguity, + }; + consumed = 19; + + if (uncompressedSection) { + sections.push(uncompressedSection); + } + } + + 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 { - // TODO: Implement item decoding - return null; + private decodeItem(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement item decoding with section emission + return { payload: null }; } - private decodeStatus(): DecodedPayload | null { - // TODO: Implement status decoding - return null; + private decodeStatus(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement status decoding with section emission + return { payload: null }; } - private decodeQuery(): DecodedPayload | null { - // TODO: Implement query decoding - return null; + private decodeQuery(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement query decoding with section emission + return { payload: null }; } - private decodeTelemetry(): DecodedPayload | null { - // TODO: Implement telemetry decoding - return null; + private decodeTelemetry(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement telemetry decoding with section emission + return { payload: null }; } - private decodeWeather(): DecodedPayload | null { - // TODO: Implement weather decoding - return null; + private decodeWeather(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement weather decoding with section emission + return { payload: null }; } - private decodeRawGPS(): DecodedPayload | null { - // TODO: Implement raw GPS decoding - return null; + private decodeRawGPS(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement raw GPS decoding with section emission + return { payload: null }; } - private decodeCapabilities(): DecodedPayload | null { - // TODO: Implement capabilities decoding - return null; + private decodeCapabilities(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement capabilities decoding with section emission + return { payload: null }; } - private decodeUserDefined(): DecodedPayload | null { - // TODO: Implement user-defined decoding - return null; + private decodeUserDefined(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement user-defined decoding with section emission + return { payload: null }; } - private decodeThirdParty(): DecodedPayload | null { - // TODO: Implement third-party decoding - return null; + private decodeThirdParty(_emitSections: boolean = false): { payload: DecodedPayload | null; sections?: Segment[] } { + // TODO: Implement third-party decoding with section emission + return { payload: null }; } } @@ -735,10 +1033,54 @@ export const parseFrame = (data: string): Frame => { throw new Error('APRS: invalid addresses in route'); } - const source = parseAddress(parts[0]); - const destinationAndPath = parts[1].split(','); - const destination = parseAddress(destinationAndPath[0]); - const path = destinationAndPath.slice(1).map(addr => parseAddress(addr)); + // Parse source - track byte offset as we parse + let offset = 0; + const sourceStr = parts[0]; + const source = parseAddress(sourceStr); - return new Frame(source, destination, path, payload); + offset += sourceStr.length + 1; // +1 for '>' + + // Parse destination and path + const destinationAndPath = parts[1].split(','); + const destinationStr = destinationAndPath[0]; + const destination = parseAddress(destinationStr); + + offset += destinationStr.length; + + // Parse path + const path: Address[] = []; + const pathAttributes: Attribute[] = []; + for (let i = 1; i < destinationAndPath.length; i++) { + offset += 1; // +1 for ',' + const pathStr = destinationAndPath[i]; + path.push(parseAddress(pathStr)); + + pathAttributes.push({ + name: `Path separator ${i}`, + byteWidth: 1, + type: 'string' + }); + pathAttributes.push({ + name: `Repeater ${i}`, + byteWidth: pathStr.length, + type: 'string' + }); + offset += pathStr.length; + } + + const routingSection: Segment = { + name: 'Routing', + offset: 0, + byteCount: offset + 1, // +1 for ':' separator after path + stringOnly: true, + attributes: [ + { byteWidth: sourceStr.length, type: 'string', name: 'Source address' }, + { byteWidth: 1, type: 'char', name: 'Route separator' }, + { byteWidth: destinationStr.length, type: 'string', name: 'Destination address' }, + ...pathAttributes, + { byteWidth: 1, type: 'char', name: 'Payload separator' }, + ], + }; + + return new Frame(source, destination, path, payload, routingSection); } diff --git a/ui/src/protocols/meshcore.test.ts b/ui/src/protocols/meshcore.test.ts index e76857d..ad8d057 100644 --- a/ui/src/protocols/meshcore.test.ts +++ b/ui/src/protocols/meshcore.test.ts @@ -15,8 +15,8 @@ import type { ResponsePayload, TextPayload, TracePayload, -} from './meshcore.types'; -import { AdvertisementFlags, PayloadType, RouteType } from './meshcore.types'; +} from '../types/protocol/meshcore.types'; +import { AdvertisementFlags, PayloadType, RouteType } from '../types/protocol/meshcore.types'; describe('GroupSecret', () => { describe('fromName', () => { @@ -651,4 +651,63 @@ describe('Packet', () => { expect(packet.getPathBytesLength()).toBe(2); }); }); + + describe('packet dissection with sections', () => { + it('should emit framework sections when emitSections is true', () => { + const payloadData = new Uint8Array([0x01, 0x02, 0x03]); + const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData); + + const packet = new Packet(packetData, true); + const result = packet.decode(true); + + expect(result.sections).toBeDefined(); + expect(result.sections?.length).toBeGreaterThan(0); + + // Should have Framework section + const frameworkSection = result.sections?.find(s => s.name === 'Framework'); + expect(frameworkSection).toBeDefined(); + expect(frameworkSection?.children).toBeDefined(); + + // Should have Header section within Framework + const headerSection = frameworkSection?.children?.find(s => s.name === 'Header'); + expect(headerSection).toBeDefined(); + expect(headerSection?.byteCount).toBe(1); + expect(headerSection?.attributes).toBeDefined(); + + // Should have Path Length section + const pathLengthSection = frameworkSection?.children?.find(s => s.name === 'Path Length'); + expect(pathLengthSection).toBeDefined(); + }); + + it('should emit payload sections when emitSections is true', () => { + const payloadData = new Uint8Array([ + 0x55, // dstHash + 0x66, // srcHash + 0xFF, 0xFF, // cipherMAC + 0xAA, 0xBB // cipherText + ]); + const packetData = createPacketData(RouteType.FLOOD, PayloadType.REQUEST, payloadData); + + const packet = new Packet(packetData, true); + const result = packet.decode(true); + + expect(result.sections).toBeDefined(); + + // Should have Payload section + const payloadSection = result.sections?.find(s => s.name === 'Payload'); + expect(payloadSection).toBeDefined(); + expect(payloadSection?.attributes).toBeDefined(); + expect(payloadSection?.attributes?.length).toBeGreaterThan(0); + }); + + it('should not emit sections when emitSections is false or omitted', () => { + const payloadData = new Uint8Array([0x01, 0x02]); + const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData); + + const packet = new Packet(packetData); + const result = packet.decode(); + + expect(result.sections).toBeUndefined(); + }); + }); }); diff --git a/ui/src/protocols/meshcore.ts b/ui/src/protocols/meshcore.ts index efc14f4..6436abb 100644 --- a/ui/src/protocols/meshcore.ts +++ b/ui/src/protocols/meshcore.ts @@ -6,6 +6,7 @@ import { sha256, sha512 } from '@noble/hashes/sha2.js'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; // Local types +import type { Attribute, Segment } from '../types/protocol/dissection.types'; import type { AckPayload, AnonReqPayload, @@ -29,7 +30,7 @@ import type { ResponsePayload, TextPayload, TracePayload, -} from './meshcore.types'; +} from '../types/protocol/meshcore.types'; // Local imports import { base64ToBytes, BufferReader, constantTimeEqual } from '../util'; @@ -39,7 +40,7 @@ import { BasePacket, PayloadType, RouteType, -} from './meshcore.types'; +} from '../types/protocol/meshcore.types'; const MAX_PATH_SIZE = 64; @@ -56,33 +57,76 @@ export const hasTransportCodes = (routeType: RouteType): boolean => { } export class Packet extends BasePacket { - constructor(data?: Uint8Array | string) { + private _frameworkSection?: Segment; + + constructor(data?: Uint8Array | string, emitSections = false) { super(); if (typeof data !== 'undefined') { if (typeof data === 'string') { data = base64ToBytes(data); } - this.parse(data); + this.parse(data, emitSections); } } - public parse(data: Uint8Array) { + public parse(data: Uint8Array, emitSections = false) { + let offset = 0; + const frameworkChildren: Segment[] = []; + const header = data[0]; this.routeType = (header >> 0) & 0x03; this.payloadType = (header >> 2) & 0x0F; this.version = (header >> 6) & 0x03; + // Build header section + if (emitSections) { + frameworkChildren.push({ + name: 'Header', + offset, + byteCount: 1, + attributes: [ + { byteWidth: 1, type: 'uint8', name: 'Header Byte' }, + ], + }); + } + offset++; + let index = 1; if (hasTransportCodes(this.routeType)) { const view = new DataView(data.buffer, index, index + 4); this.transportCodes = new Uint16Array(2); this.transportCodes[0] = view.getUint16(0, true); this.transportCodes[1] = view.getUint16(2, true); + + if (emitSections) { + frameworkChildren.push({ + name: 'Transport Codes', + offset, + byteCount: 4, + attributes: [ + { byteWidth: 2, type: 'uint16le', name: 'Transport Code 1' }, + { byteWidth: 2, type: 'uint16le', name: 'Transport Code 2' }, + ], + }); + } index += 4; + offset += 4; } this.pathLength = data[index]; + if (emitSections) { + frameworkChildren.push({ + name: 'Path Length', + offset, + byteCount: 1, + attributes: [ + { byteWidth: 1, type: 'uint8', name: 'Path Length' }, + ], + }); + } index++; + offset++; + if (!this.isValidPathLength()) { throw new Error(`MeshCore: invalid path length ${this.pathLength}`) } @@ -94,10 +138,31 @@ export class Packet extends BasePacket { index++; } + if (emitSections) { + frameworkChildren.push({ + name: 'Path', + offset, + byteCount: pathBytesLength, + attributes: [ + { byteWidth: pathBytesLength, type: 'uint8', name: 'Path Bytes' }, + ], + }); + } + offset += pathBytesLength; + if (index >= data.length) { throw new Error('MeshCore: invalid packet: no payload'); } + if (emitSections) { + this._frameworkSection = { + name: 'Framework', + offset: 0, + byteCount: offset, + children: frameworkChildren, + }; + } + const payloadBytesLength = data.length - index; this.payload = new Uint8Array(payloadBytesLength); for (let i = 0; i < payloadBytesLength; i++) { @@ -110,6 +175,10 @@ export class Packet extends BasePacket { return this.parse(base64ToBytes(data)); } + public getFrameworkSection(): Segment | undefined { + return this._frameworkSection; + } + private isValidPathLength(): boolean { const hashCount = this.getPathHashCount(); const hashSize = this.getPathHashSize(); @@ -139,160 +208,372 @@ export class Packet extends BasePacket { return hash.slice(0, 8); } - public decode(): Payload { + public decode(): Payload; + public decode(emitSections: true): { payload: Payload; sections?: Segment[] }; + public decode(emitSections = false): Payload | { payload: Payload; sections?: Segment[] } { + let decodedPayload: Payload; + let payloadSections: Segment[] | undefined; + switch (this.payloadType) { case PayloadType.REQUEST: - return this.decodeRequest(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeRequest(emitSections)); + break; case PayloadType.RESPONSE: - return this.decodeResponse(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeResponse(emitSections)); + break; case PayloadType.TEXT: - return this.decodeText(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeText(emitSections)); + break; case PayloadType.ACK: - return this.decodeAck(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeAck(emitSections)); + break; case PayloadType.ADVERT: - return this.decodeAdvert(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeAdvert(emitSections)); + break; case PayloadType.GROUP_TEXT: - return this.decodeGroupText(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupText(emitSections)); + break; case PayloadType.GROUP_DATA: - return this.decodeGroupData(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupData(emitSections)); + break; case PayloadType.ANON_REQ: - return this.decodeAnonReq(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeAnonReq(emitSections)); + break; case PayloadType.PATH: - return this.decodePath(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodePath(emitSections)); + break; case PayloadType.TRACE: - return this.decodeTrace(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeTrace(emitSections)); + break; case PayloadType.MULTIPART: - return this.decodeMultipart(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeMultipart(emitSections)); + break; case PayloadType.CONTROL: - return this.decodeControl(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeControl(emitSections)); + break; case PayloadType.RAW_CUSTOM: - return this.decodeRawCustom(); + ({ payload: decodedPayload, sections: payloadSections } = this.decodeRawCustom(emitSections)); + break; default: throw new Error(`MeshCore: can't decode payload ${this.payloadType}`) } + + if (!emitSections) { + return decodedPayload; + } + + 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(kind: PayloadType): T { + private decodeEncrypted(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } { + let offset = 0; + const sections: Segment[] = []; const buffer = new BufferReader(this.payload); + + const dstHash = buffer.readUint8().toString(16).padStart(2, '0'); + const srcHash = buffer.readUint8().toString(16).padStart(2, '0'); + const cipherMAC = buffer.readBytes(2); + const cipherText = buffer.readBytes(); + + if (emitSections) { + sections.push({ + name: 'Payload', + offset, + byteCount: this.payload.length, + attributes: [ + { byteWidth: 1, type: 'uint8', name: 'Destination Hash' }, + { byteWidth: 1, type: 'uint8', name: 'Source Hash' }, + { byteWidth: 2, type: 'uint8', name: 'Cipher MAC' }, + { byteWidth: cipherText.length, type: 'uint8', name: 'Cipher Text' }, + ], + }); + } + return { - payloadType: kind, - dstHash: buffer.readUint8().toString(16).padStart(2, '0'), - srcHash: buffer.readUint8().toString(16).padStart(2, '0'), - cipherMAC: buffer.readBytes(2), - cipherText: buffer.readBytes() - } as T + payload: { + payloadType: kind, + dstHash, + srcHash, + cipherMAC, + cipherText, + } as unknown as T, + sections: emitSections ? sections : undefined, + }; } - private decodeRequest(): RequestPayload { - return this.decodeEncrypted(PayloadType.REQUEST); + private decodeRequest(emitSections = false): { payload: RequestPayload; sections?: Segment[] } { + return this.decodeEncrypted(PayloadType.REQUEST, emitSections); } - private decodeResponse(): ResponsePayload { - return this.decodeEncrypted(PayloadType.RESPONSE); + private decodeResponse(emitSections = false): { payload: ResponsePayload; sections?: Segment[] } { + return this.decodeEncrypted(PayloadType.RESPONSE, emitSections); } - private decodeText(): TextPayload { - return this.decodeEncrypted(PayloadType.TEXT); + private decodeText(emitSections = false): { payload: TextPayload; sections?: Segment[] } { + return this.decodeEncrypted(PayloadType.TEXT, emitSections); } - private decodeAck(): AckPayload { - return this.decodeEncrypted(PayloadType.ACK); + private decodeAck(emitSections = false): { payload: AckPayload; sections?: Segment[] } { + return this.decodeEncrypted(PayloadType.ACK, emitSections); } - private decodeAdvert(): AdvertPayload { + private decodeAdvert(emitSections = false): { payload: AdvertPayload; sections?: Segment[] } { + let offset = 0; + const sections: Segment[] = []; + const attributes: Attribute[] = []; + const buffer = new BufferReader(this.payload); const publicKey = buffer.readBytes(32); + attributes.push({ byteWidth: 32, type: 'uint8', name: 'Public Key' }); + const timestampValue = buffer.readUint32LE(); + attributes.push({ byteWidth: 4, type: 'uint32le', name: 'timestamp' }); + const signature = buffer.readBytes(64); - let payload: AdvertPayload = { - payloadType: PayloadType.ADVERT, - publicKey: publicKey, - timestamp: timestampValue === 0 ? undefined : new Date(timestampValue), - signature: signature, - appdata: { - flags: buffer.readUint8(), - } - } - if (payload.appdata.flags & AdvertisementFlags.HAS_LOCATION) { - payload.appdata.latitude = buffer.readUint32LE() / 1000000.0 - payload.appdata.longitude = buffer.readUint32LE() / 1000000.0 - } - if (payload.appdata.flags & AdvertisementFlags.HAS_FEATURE_1) { - payload.appdata.feature1 = buffer.readUint16LE() - } - if (payload.appdata.flags & AdvertisementFlags.HAS_FEATURE_2) { - payload.appdata.feature2 = buffer.readUint16LE() - } - if (payload.appdata.flags & AdvertisementFlags.HAS_NAME) { - payload.appdata.name = new TextDecoder().decode(buffer.readBytes()).toString() - } - return payload; - } + attributes.push({ byteWidth: 64, type: 'uint8', name: 'signature' }); + + const flags = buffer.readUint8(); + attributes.push({ byteWidth: 1, type: 'uint8', name: 'flags' }); + + const appdata: { flags: number; latitude?: number; longitude?: number; feature1?: number; feature2?: number; name?: string } = { + flags, + }; + + if (flags & AdvertisementFlags.HAS_LOCATION) { + const latitude = buffer.readUint32LE() / 1000000.0; + const longitude = buffer.readUint32LE() / 1000000.0; + appdata.latitude = latitude; + appdata.longitude = longitude; + attributes.push({ byteWidth: 4, type: 'uint32le', name: 'latitude' }); + attributes.push({ byteWidth: 4, type: 'uint32le', name: 'longitude' }); + } + if (flags & AdvertisementFlags.HAS_FEATURE_1) { + const feature1 = buffer.readUint16LE(); + appdata.feature1 = feature1; + attributes.push({ byteWidth: 2, type: 'uint16le', name: 'Feature 1' }); + } + if (flags & AdvertisementFlags.HAS_FEATURE_2) { + const feature2 = buffer.readUint16LE(); + appdata.feature2 = feature2; + attributes.push({ byteWidth: 2, type: 'uint16le', name: 'Feature 2' }); + } + if (flags & AdvertisementFlags.HAS_NAME) { + const nameBytes = buffer.readBytes(); + const name = new TextDecoder().decode(nameBytes).toString(); + appdata.name = name; + attributes.push({ byteWidth: nameBytes.length, type: 'string', name: 'name' }); + } + + if (emitSections) { + sections.push({ + name: 'Payload', + offset, + byteCount: this.payload.length, + attributes, + }); + } - private decodeGroupEncrypted(kind: PayloadType): T { - const buffer = new BufferReader(this.payload); return { - payloadType: kind, - channelHash: buffer.readUint8().toString(16).padStart(2, '0'), - cipherMAC: buffer.readBytes(2), - cipherText: buffer.readBytes() - } as T; + payload: { + payloadType: PayloadType.ADVERT, + publicKey, + timestamp: timestampValue === 0 ? undefined : new Date(timestampValue), + signature, + appdata, + }, + sections: emitSections ? sections : undefined, + }; } - private decodeGroupText(): GroupTextPayload { - return this.decodeGroupEncrypted(PayloadType.GROUP_TEXT); - } - - private decodeGroupData(): GroupDataPayload { - return this.decodeGroupEncrypted(PayloadType.GROUP_DATA); - } - - private decodeAnonReq(): AnonReqPayload { + private decodeGroupEncrypted(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } { + let offset = 0; + const sections: Segment[] = []; const buffer = new BufferReader(this.payload); - return { - payloadType: PayloadType.ANON_REQ, - dstHash: buffer.readUint8().toString(16).padStart(2, '0'), - publicKey: buffer.readBytes(32), - cipherMAC: buffer.readBytes(2), - cipherText: buffer.readBytes() + + const channelHash = buffer.readUint8().toString(16).padStart(2, '0'); + const cipherMAC = buffer.readBytes(2); + const cipherText = buffer.readBytes(); + + if (emitSections) { + sections.push({ + name: 'Payload', + offset, + byteCount: this.payload.length, + attributes: [ + { byteWidth: 1, type: 'uint8', name: 'Channel Hash' }, + { byteWidth: 2, type: 'uint8', name: 'Cipher MAC' }, + { byteWidth: cipherText.length, type: 'uint8', name: 'Cipher Text' }, + ], + }); } + + return { + payload: { + payloadType: kind, + channelHash, + cipherMAC, + cipherText, + } as unknown as T, + sections: emitSections ? sections : undefined, + }; } - private decodePath(): PathPayload { - return this.decodeEncrypted(PayloadType.PATH); + private decodeGroupText(emitSections = false): { payload: GroupTextPayload; sections?: Segment[] } { + return this.decodeGroupEncrypted(PayloadType.GROUP_TEXT, emitSections); } - private decodeTrace(): TracePayload { + private decodeGroupData(emitSections = false): { payload: GroupDataPayload; sections?: Segment[] } { + return this.decodeGroupEncrypted(PayloadType.GROUP_DATA, emitSections); + } + + private decodeAnonReq(emitSections = false): { payload: AnonReqPayload; sections?: Segment[] } { + const sections: Segment[] = []; const buffer = new BufferReader(this.payload); - return { - payloadType: PayloadType.TRACE, - data: buffer.readBytes() + + const dstHash = buffer.readUint8().toString(16).padStart(2, '0'); + const publicKey = buffer.readBytes(32); + const cipherMAC = buffer.readBytes(2); + const cipherText = buffer.readBytes(); + + if (emitSections) { + sections.push({ + name: 'Payload', + offset: 0, + byteCount: this.payload.length, + attributes: [ + { byteWidth: 1, type: 'uint8', name: 'Destination Hash' }, + { byteWidth: 32, type: 'uint8', name: 'Public Key' }, + { byteWidth: 2, type: 'uint8', name: 'Cipher MAC' }, + { byteWidth: cipherText.length, type: 'uint8', name: 'Cipher Text' }, + ], + }); } + + return { + payload: { + payloadType: PayloadType.ANON_REQ, + dstHash, + publicKey, + cipherMAC, + cipherText, + }, + sections: emitSections ? sections : undefined, + }; } - private decodeMultipart(): MultipartPayload { - const buffer = new BufferReader(this.payload); - return { - payloadType: PayloadType.MULTIPART, - data: buffer.readBytes() - } + private decodePath(emitSections = false): { payload: PathPayload; sections?: Segment[] } { + return this.decodeEncrypted(PayloadType.PATH, emitSections); } - private decodeControl(): ControlPayload { + private decodeTrace(emitSections = false): { payload: TracePayload; sections?: Segment[] } { + const sections: Segment[] = []; const buffer = new BufferReader(this.payload); - return { - payloadType: PayloadType.CONTROL, - flags: buffer.readUint8(), - data: buffer.readBytes() + const data = buffer.readBytes(); + + if (emitSections) { + sections.push({ + name: 'Payload', + offset: 0, + byteCount: this.payload.length, + attributes: [ + { byteWidth: data.length, type: 'uint8', name: 'data' }, + ], + }); } + + return { + payload: { + payloadType: PayloadType.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); - return { - payloadType: PayloadType.RAW_CUSTOM, - data: buffer.readBytes() + const data = buffer.readBytes(); + + if (emitSections) { + sections.push({ + name: 'Payload', + offset: 0, + byteCount: this.payload.length, + attributes: [ + { byteWidth: data.length, type: 'uint8', name: 'data' }, + ], + }); } + + return { + payload: { + payloadType: PayloadType.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, + }; } } diff --git a/ui/src/services/APRSService.ts b/ui/src/services/APRSService.ts index cdfdd3c..f827dff 100644 --- a/ui/src/services/APRSService.ts +++ b/ui/src/services/APRSService.ts @@ -1,6 +1,7 @@ import type { APIService } from './API'; +import type { Packet } from '../types/protocol.types'; -export interface FetchedAPRSPacket { +export interface FetchedAPRSPacket extends Packet { id?: number; radio_id?: number; radio?: { diff --git a/ui/src/services/APRSStream.ts b/ui/src/services/APRSStream.ts index abc025a..a61c388 100644 --- a/ui/src/services/APRSStream.ts +++ b/ui/src/services/APRSStream.ts @@ -1,10 +1,9 @@ import { BaseStream } from './Stream'; +import type { Packet } from '../types/protocol.types'; -export interface APRSMessage { +export interface APRSMessage extends Packet { topic: string; - receivedAt: Date; raw: string; - radioName?: string; } interface APRSJsonEnvelope { @@ -17,6 +16,8 @@ interface APRSJsonEnvelope { Time?: string; time?: string; timestamp?: string; + snr?: number; + rssi?: number; } const fromBase64 = (value: string): string => { @@ -62,6 +63,8 @@ export class APRSStream extends BaseStream { receivedAt, raw, radioName, + snr: envelope.snr, + rssi: envelope.rssi, }; } diff --git a/ui/src/services/MeshCoreService.ts b/ui/src/services/MeshCoreService.ts index b1fc63f..598f7df 100644 --- a/ui/src/services/MeshCoreService.ts +++ b/ui/src/services/MeshCoreService.ts @@ -1,6 +1,7 @@ import type { APIService } from './API'; -import type { Group } from '../protocols/meshcore.types'; -import { PayloadType } from '../protocols/meshcore.types'; +import type { Group } from '../types/protocol/meshcore.types'; +import type { Packet } from '../types/protocol.types'; +import { PayloadType } from '../types/protocol/meshcore.types'; import { GroupSecret } from '../protocols/meshcore'; interface FetchedMeshCoreGroup { @@ -15,11 +16,9 @@ export type MeshCoreGroupRecord = Group & { isPublic: boolean; }; -export interface FetchedGroupPacket { +export interface FetchedMeshCorePacket extends Packet { id: number; radio_id: number; - snr: number; - rssi: number; version: number; route_type: number; payload_type: number; @@ -57,12 +56,24 @@ export class MeshCoreServiceImpl { /** * Fetch all MeshCore packets * @param limit Maximum number of packets to fetch (default: 200) + * @param type Optional payload type to filter by + * @param channelHash Optional channel hash to filter by * @returns Array of raw packet data */ - public async fetchPackets(limit = 200): Promise { + public async fetchPackets( + limit = 200, + type?: number, + channelHash?: string, + ): Promise { const endpoint = '/meshcore/packets'; - const params = { limit }; - return this.api.fetch(endpoint, { params }); + const params: Record = { limit }; + if (type !== undefined) { + params.type = type; + } + if (channelHash !== undefined) { + params.channel_hash = channelHash; + } + return this.api.fetch(endpoint, { params }); } /** @@ -70,13 +81,8 @@ export class MeshCoreServiceImpl { * @param channelHash The channel hash to fetch packets for * @returns Array of raw packet data */ - public async fetchGroupPackets(channelHash: string): Promise { - const endpoint = '/meshcore/packets'; - const params = { - type: PayloadType.GROUP_TEXT, - channel_hash: channelHash, - }; - return this.api.fetch(endpoint, { params }); + public async fetchGroupPackets(channelHash: string): Promise { + return this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash); } } diff --git a/ui/src/services/MeshCoreStream.ts b/ui/src/services/MeshCoreStream.ts index b4e9c2e..76f2c74 100644 --- a/ui/src/services/MeshCoreStream.ts +++ b/ui/src/services/MeshCoreStream.ts @@ -1,43 +1,31 @@ -import { bytesToHex } from '@noble/hashes/utils.js'; -import { Packet } from '../protocols/meshcore'; -import type { Payload } from '../protocols/meshcore.types'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; +import { Packet as MeshCorePacket } from '../protocols/meshcore'; +import type { Payload } from '../types/protocol/meshcore.types'; +import type { Packet } from '../types/protocol.types'; import { BaseStream } from './Stream'; -export interface MeshCoreMessage { +export interface MeshCoreMessage extends Packet { topic: string; - receivedAt: Date; raw: Uint8Array; hash: string; decodedPayload?: Payload; - radioName?: string; } interface MeshCoreJsonEnvelope { payloadBase64?: string; payloadHex?: string; + snr?: number; + rssi?: number; } -const hexToBytes = (hex: string): Uint8Array => { - const normalized = hex.trim().toLowerCase(); - if (normalized.length % 2 !== 0) { - throw new Error('MeshCoreStream: invalid hex payload length'); - } - - const bytes = new Uint8Array(normalized.length / 2); - for (let i = 0; i < normalized.length; i += 2) { - bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16); - } - return bytes; -}; - export class MeshCoreStream extends BaseStream { constructor(autoConnect = false) { super({}, autoConnect); } protected decodeMessage(topic: string, payload: Uint8Array): MeshCoreMessage { - const packetBytes = this.extractPacketBytes(payload); - const parsed = new Packet(); + const { bytes: packetBytes, snr, rssi } = this.extractPacketBytes(payload); + const parsed = new MeshCorePacket(); parsed.parse(packetBytes); let decodedPayload: Payload | undefined; @@ -58,7 +46,9 @@ export class MeshCoreStream extends BaseStream { raw: packetBytes, hash: bytesToHex(parsed.hash()), decodedPayload, - radioName + radioName, + snr, + rssi, }; } @@ -77,21 +67,25 @@ export class MeshCoreStream extends BaseStream { return undefined; } - private extractPacketBytes(payload: Uint8Array): Uint8Array { + private extractPacketBytes(payload: Uint8Array): { bytes: Uint8Array; snr?: number; rssi?: number } { const text = new TextDecoder().decode(payload).trim(); if (!text.startsWith('{')) { - return payload; + return { bytes: payload }; } const envelope = JSON.parse(text) as MeshCoreJsonEnvelope; + let bytes: Uint8Array = payload; + if (envelope.payloadBase64) { - return Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0)); + bytes = Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0)); + } else if (envelope.payloadHex) { + bytes = hexToBytes(envelope.payloadHex); } - if (envelope.payloadHex) { - return hexToBytes(envelope.payloadHex); - } - - return payload; + return { + bytes, + snr: envelope.snr, + rssi: envelope.rssi, + }; } } diff --git a/ui/src/types/protocol.types.ts b/ui/src/types/protocol.types.ts new file mode 100644 index 0000000..ec5577c --- /dev/null +++ b/ui/src/types/protocol.types.ts @@ -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; +} diff --git a/ui/src/protocols/aprs.types.ts b/ui/src/types/protocol/aprs.types.ts similarity index 98% rename from ui/src/protocols/aprs.types.ts rename to ui/src/types/protocol/aprs.types.ts index bcdbf8a..8c6e645 100644 --- a/ui/src/protocols/aprs.types.ts +++ b/ui/src/types/protocol/aprs.types.ts @@ -1,3 +1,5 @@ +import type { Segment } from './dissection.types'; + export interface Address { call: string; ssid: string; @@ -96,6 +98,7 @@ export interface PositionPayload { messageType?: string; isStandard?: boolean; }; + sections?: Segment[]; } // Compressed Position Format @@ -305,4 +308,5 @@ export type DecodedPayload = // Extended Frame with decoded payload export interface DecodedFrame extends Frame { decoded?: DecodedPayload; + sections?: Segment[]; // Routing and other frame-level sections } diff --git a/ui/src/types/protocol/dissection.types.ts b/ui/src/types/protocol/dissection.types.ts new file mode 100644 index 0000000..8e70a33 --- /dev/null +++ b/ui/src/types/protocol/dissection.types.ts @@ -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) +} diff --git a/ui/src/protocols/meshcore.types.ts b/ui/src/types/protocol/meshcore.types.ts similarity index 95% rename from ui/src/protocols/meshcore.types.ts rename to ui/src/types/protocol/meshcore.types.ts index e1090ca..85c1f99 100644 --- a/ui/src/protocols/meshcore.types.ts +++ b/ui/src/types/protocol/meshcore.types.ts @@ -1,3 +1,5 @@ +import type { Segment } from './dissection.types'; + export type NodeHash = string; // first byte of the hash export const RouteType = { @@ -35,6 +37,7 @@ export interface Packet { payloadType: PayloadType; path: Uint8Array; payload: Uint8Array; + sections?: Segment[]; } export abstract class BasePacket { @@ -100,6 +103,7 @@ export interface RequestPayload extends EncryptedPayload { readonly payloadType: typeof PayloadType.REQUEST; dstHash: NodeHash; srcHash: NodeHash; + sections?: Segment[]; } export interface DecryptedRequest { @@ -136,6 +140,7 @@ export interface ResponsePayload extends EncryptedPayload { readonly payloadType: typeof PayloadType.RESPONSE; dstHash: NodeHash; srcHash: NodeHash; + sections?: Segment[]; } export interface DecryptedResponse { @@ -155,6 +160,7 @@ export interface TextPayload extends EncryptedPayload { readonly payloadType: typeof PayloadType.TEXT; dstHash: NodeHash; srcHash: NodeHash; + sections?: Segment[]; } export interface DecryptedTextMessage { @@ -167,9 +173,11 @@ export interface SignedTextMessage extends DecryptedTextMessage { senderPubkeyPrefix: Uint8Array; // First 4 bytes of sender pubkey (when txt_type = 0x02) } -export interface AckPayload { +export interface AckPayload extends EncryptedPayload { readonly payloadType: typeof PayloadType.ACK; - checksum: number; // 4 bytes, LE - CRC of message timestamp, text, and sender pubkey + dstHash: NodeHash; + srcHash: NodeHash; + sections?: Segment[]; } export const AdvertisementFlags = { @@ -200,6 +208,7 @@ export interface AdvertPayload { timestamp?: Date; // Unix timestamp (4 bytes, LE), undefined if timestamp is 0 signature: Uint8Array; // 64 bytes Ed25519 signature appdata: AdvertisementAppData; + sections?: Segment[]; } export interface EncryptedGroupPayload extends EncryptedPayload { @@ -208,10 +217,12 @@ export interface EncryptedGroupPayload extends EncryptedPayload { export interface GroupTextPayload extends EncryptedGroupPayload { readonly payloadType: typeof PayloadType.GROUP_TEXT; + sections?: Segment[]; } export interface GroupDataPayload extends EncryptedGroupPayload { readonly payloadType: typeof PayloadType.GROUP_DATA; + sections?: Segment[]; } export interface DecryptedGroupMessage { @@ -225,6 +236,7 @@ export interface AnonReqPayload extends EncryptedPayload { readonly payloadType: typeof PayloadType.ANON_REQ; dstHash: NodeHash; // first byte of destination node public key publicKey: Uint8Array; // 32 bytes - sender's ephemeral Ed25519 public key + sections?: Segment[]; } export const AnonRequestSubType = { @@ -273,6 +285,7 @@ export interface PathPayload extends EncryptedPayload { readonly payloadType: typeof PayloadType.PATH; dstHash: NodeHash; srcHash: NodeHash; + sections?: Segment[]; } export interface DecryptedPath { @@ -286,11 +299,13 @@ export interface TracePayload { readonly payloadType: typeof PayloadType.TRACE; // Format not fully specified in docs - collecting SNI for each hop data: Uint8Array; + sections?: Segment[]; } export interface MultipartPayload { readonly payloadType: typeof PayloadType.MULTIPART; data: Uint8Array; // One part of a multi-part packet + sections?: Segment[]; } export const ControlSubType = { @@ -314,6 +329,7 @@ export interface ControlPayload { readonly payloadType: typeof PayloadType.CONTROL; flags: number; // Upper 4 bits is sub_type data: Uint8Array; + sections?: Segment[]; } export interface DiscoverRequest { @@ -335,6 +351,7 @@ export interface DiscoverResponse { export interface RawCustomPayload { readonly payloadType: typeof PayloadType.RAW_CUSTOM; data: Uint8Array; // Raw bytes for custom encryption/application + sections?: Segment[]; } export interface Group { diff --git a/ui/src/types/radio.types.ts b/ui/src/types/radio.types.ts index 8cce26f..dfa3a55 100644 --- a/ui/src/types/radio.types.ts +++ b/ui/src/types/radio.types.ts @@ -1,3 +1,5 @@ +import type { Modulation, Protocol } from './protocol.types'; + export interface Radio { id: number; name: string; @@ -7,8 +9,8 @@ export interface Radio { firmware_version: string | null; firmware_date: string | null; antenna: string | null; - modulation: string; - protocol: string; + modulation: Modulation; + protocol: Protocol; latitude?: number; longitude?: number; altitude?: number;