import { MeshCoreDecoder, PayloadType, Utils, type DecodedPacket, type DecryptionOptions, type HeaderBreakdown, type PacketStructure, } from '@michaelhart/meshcore-decoder'; import type { Channel, RawPacket } from '../types'; export interface RawPacketSummary { summary: string; routeType: string; details?: string; } export interface PacketByteField { id: string; scope: 'packet' | 'payload'; name: string; description: string; value: string; decryptedMessage?: string; startByte: number; endByte: number; absoluteStartByte: number; absoluteEndByte: number; headerBreakdown?: HeaderBreakdown; } export interface RawPacketInspection { decoded: DecodedPacket | null; structure: PacketStructure | null; routeTypeName: string; payloadTypeName: string; payloadVersionName: string; pathTokens: string[]; summary: RawPacketSummary; validationErrors: string[]; packetFields: PacketByteField[]; payloadFields: PacketByteField[]; } export function formatHexByHop(hex: string, hashSize: number | null | undefined): string { const normalized = hex.trim().toUpperCase(); if (!normalized || !hashSize || hashSize < 1) { return normalized; } const charsPerHop = hashSize * 2; if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) { return normalized; } const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g')); return hops && hops.length > 1 ? hops.join(' → ') : normalized; } export function describeCiphertextStructure( payloadType: PayloadType, byteLength: number, fallbackDescription: string ): string { switch (payloadType) { case PayloadType.GroupText: return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure: • Timestamp (4 bytes) - send time as unix timestamp • Flags (1 byte) - channel-message flags byte • Message (remaining bytes) - UTF-8 channel message text`; case PayloadType.TextMessage: return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure: • Timestamp (4 bytes) - send time as unix timestamp • Message (remaining bytes) - UTF-8 direct message text`; case PayloadType.Response: return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure: • Tag (4 bytes) - request/response correlation tag • Content (remaining bytes) - response body`; default: return fallbackDescription; } } function getPathTokens(decoded: DecodedPacket): string[] { const tracePayload = decoded.payloadType === PayloadType.Trace && decoded.payload.decoded ? (decoded.payload.decoded as { pathHashes?: string[] }) : null; return tracePayload?.pathHashes || decoded.path || []; } function formatUnixTimestamp(timestamp: number): string { return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`; } function createPacketField( scope: 'packet' | 'payload', id: string, field: { name: string; description: string; value: string; decryptedMessage?: string; startByte: number; endByte: number; headerBreakdown?: HeaderBreakdown; }, absoluteOffset: number ): PacketByteField { return { id, scope, name: field.name, description: field.description, value: field.value, decryptedMessage: field.decryptedMessage, startByte: field.startByte, endByte: field.endByte, absoluteStartByte: absoluteOffset + field.startByte, absoluteEndByte: absoluteOffset + field.endByte, headerBreakdown: field.headerBreakdown, }; } export function createDecoderOptions( channels: Channel[] | null | undefined ): DecryptionOptions | undefined { const channelSecrets = channels ?.map((channel) => channel.key?.trim()) .filter((key): key is string => Boolean(key && key.length > 0)) ?? []; if (channelSecrets.length === 0) { return undefined; } return { keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }), attemptDecryption: true, }; } function safeValidate(hexData: string): string[] { try { const validation = MeshCoreDecoder.validate(hexData); return validation.errors ?? []; } catch (error) { return [error instanceof Error ? error.message : 'Packet validation failed']; } } export function decodePacketSummary( packet: RawPacket, decoderOptions?: DecryptionOptions ): RawPacketSummary { try { const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions); if (!decoded.isValid) { return { summary: 'Invalid packet', routeType: 'Unknown' }; } const routeType = Utils.getRouteTypeName(decoded.routeType); const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType); const pathTokens = getPathTokens(decoded); const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : ''; let summary = payloadTypeName; let details: string | undefined; switch (decoded.payloadType) { case PayloadType.TextMessage: { const payload = decoded.payload.decoded as { destinationHash?: string; sourceHash?: string; } | null; if (payload?.sourceHash && payload?.destinationHash) { summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`; } else { summary = `DM${pathStr}`; } break; } case PayloadType.GroupText: { const payload = decoded.payload.decoded as { channelHash?: string; decrypted?: { sender?: string; message?: string }; } | null; if (packet.decrypted_info?.channel_name) { if (packet.decrypted_info.sender) { summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`; } else { summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`; } } else if (payload?.decrypted?.sender) { summary = `GT from ${payload.decrypted.sender}${pathStr}`; } else if (payload?.decrypted?.message) { summary = `GT decrypted${pathStr}`; } else if (payload?.channelHash) { summary = `GT ch:${payload.channelHash}${pathStr}`; } else { summary = `GroupText${pathStr}`; } break; } case PayloadType.Advert: { const payload = decoded.payload.decoded as { publicKey?: string; appData?: { name?: string; deviceRole?: number }; } | null; if (payload?.appData?.name) { const role = payload.appData.deviceRole !== undefined ? Utils.getDeviceRoleName(payload.appData.deviceRole) : ''; summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`; } else if (payload?.publicKey) { summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`; } else { summary = `Advert${pathStr}`; } break; } case PayloadType.Ack: summary = `ACK${pathStr}`; break; case PayloadType.Request: summary = `Request${pathStr}`; break; case PayloadType.Response: summary = `Response${pathStr}`; break; case PayloadType.Trace: summary = `Trace${pathStr}`; break; case PayloadType.Path: summary = `Path${pathStr}`; break; default: summary = `${payloadTypeName}${pathStr}`; break; } return { summary, routeType, details }; } catch { return { summary: 'Decode error', routeType: 'Unknown' }; } } export function inspectRawPacket(packet: RawPacket): RawPacketInspection { return inspectRawPacketWithOptions(packet); } export function inspectRawPacketWithOptions( packet: RawPacket, decoderOptions?: DecryptionOptions ): RawPacketInspection { const summary = decodePacketSummary(packet, decoderOptions); const validationErrors = safeValidate(packet.data); let decoded: DecodedPacket | null = null; let structure: PacketStructure | null = null; try { decoded = MeshCoreDecoder.decode(packet.data, decoderOptions); } catch { decoded = null; } try { structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions); } catch { structure = null; } const routeTypeName = decoded?.isValid ? Utils.getRouteTypeName(decoded.routeType) : summary.routeType; const payloadTypeName = decoded?.isValid ? Utils.getPayloadTypeName(decoded.payloadType) : packet.payload_type; const payloadVersionName = decoded?.isValid ? Utils.getPayloadVersionName(decoded.payloadVersion) : 'Unknown'; const pathTokens = decoded?.isValid ? getPathTokens(decoded) : []; const packetFields = structure?.segments .map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0)) .map((field) => { if (field.name !== 'Path Data') { return field; } const hashSize = decoded?.pathHashSize ?? (decoded?.pathLength && decoded.pathLength > 0 ? Math.max(1, field.value.length / 2 / decoded.pathLength) : null); return { ...field, value: formatHexByHop(field.value, hashSize), }; }) ?? []; const payloadFields = structure == null ? [] : (structure.payload.segments.length > 0 ? structure.payload.segments : structure.payload.hex.length > 0 ? [ { name: 'Payload Bytes', description: 'Field-level payload breakdown is not available for this packet type.', startByte: 0, endByte: Math.max(0, structure.payload.hex.length / 2 - 1), value: structure.payload.hex, }, ] : [] ).map((segment, index) => createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte) ); const enrichedPayloadFields = decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded ? payloadFields.map((field) => { if (field.name !== 'Ciphertext') { return field; } const payload = decoded.payload.decoded as { decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string }; }; if (!payload.decrypted?.message) { return field; } const detailLines = [ payload.decrypted.timestamp != null ? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}` : null, payload.decrypted.flags != null ? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}` : null, payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null, `Message: ${payload.decrypted.message}`, ].filter((line): line is string => line !== null); return { ...field, description: describeCiphertextStructure( decoded.payloadType, field.endByte - field.startByte + 1, field.description ), decryptedMessage: detailLines.join('\n'), }; }) : payloadFields.map((field) => { if (!decoded?.isValid || field.name !== 'Ciphertext') { return field; } return { ...field, description: describeCiphertextStructure( decoded.payloadType, field.endByte - field.startByte + 1, field.description ), }; }); return { decoded, structure, routeTypeName, payloadTypeName, payloadVersionName, pathTokens, summary, validationErrors: validationErrors.length > 0 ? validationErrors : (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])), packetFields, payloadFields: enrichedPayloadFields, }; }