Checkpoint
This commit is contained in:
385
ui/src/pages/meshcore/MeshCoreData.tsx
Normal file
385
ui/src/pages/meshcore/MeshCoreData.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
|
||||
import { Packet } from '../../protocols/meshcore';
|
||||
import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types';
|
||||
import { MeshCoreStream } from '../../services/MeshCoreStream';
|
||||
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
|
||||
import type { Payload } from '../../protocols/meshcore.types';
|
||||
|
||||
import {
|
||||
MeshCoreDataContext,
|
||||
type MeshCoreDataContextValue,
|
||||
type MeshCorePacketRecord,
|
||||
type MeshCoreGroupChatRecord,
|
||||
type MeshCoreNodePoint,
|
||||
} from './MeshCoreContext';
|
||||
|
||||
export {
|
||||
MeshCoreDataContext,
|
||||
useMeshCoreData,
|
||||
type MeshCoreDataContextValue,
|
||||
type MeshCorePacketRecord,
|
||||
type MeshCoreGroupChatRecord,
|
||||
type MeshCoreNodePoint,
|
||||
} from './MeshCoreContext';
|
||||
|
||||
export const payloadNameByValue = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => [value, name])
|
||||
) as Record<number, string>;
|
||||
|
||||
export const nodeTypeNameByValue = Object.fromEntries(
|
||||
Object.entries(NodeType).map(([name, value]) => [value, name])
|
||||
) as Record<number, string>;
|
||||
|
||||
export const routeTypeNameByValue = Object.fromEntries(
|
||||
Object.entries(RouteType).map(([name, value]) => [value, name])
|
||||
) as Record<number, string>;
|
||||
|
||||
export const payloadValueByName = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => [name, value])
|
||||
) as Record<string, number>;
|
||||
|
||||
export const nodeTypeValueByName = Object.fromEntries(
|
||||
Object.entries(NodeType).map(([name, value]) => [name, value])
|
||||
) as Record<string, number>;
|
||||
|
||||
export const routeTypeValueByName = Object.fromEntries(
|
||||
Object.entries(RouteType).map(([name, value]) => [name, value])
|
||||
) as Record<string, number>;
|
||||
|
||||
// Human-readable display names (for UI)
|
||||
export const nodeTypeDisplayByValue: Record<number, string> = {
|
||||
[NodeType.TYPE_UNKNOWN]: 'Unknown',
|
||||
[NodeType.TYPE_CHAT_NODE]: 'Chat Node',
|
||||
[NodeType.TYPE_REPEATER]: 'Repeater',
|
||||
[NodeType.TYPE_ROOM_SERVER]: 'Room Server',
|
||||
[NodeType.TYPE_SENSOR]: 'Sensor',
|
||||
};
|
||||
|
||||
export const payloadDisplayByValue: Record<number, string> = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => {
|
||||
const cleanName = name.replace(/^PAYLOAD_TYPE_/, '').replace(/_/g, ' ');
|
||||
return [value, cleanName.charAt(0) + cleanName.slice(1).toLowerCase()];
|
||||
})
|
||||
) as Record<number, string>;
|
||||
|
||||
export const routeDisplayByValue: Record<number, string> = Object.fromEntries(
|
||||
Object.entries(RouteType).map(([name, value]) => {
|
||||
const cleanName = name.replace(/^ROUTE_TYPE_/, '').replace(/_/g, ' ');
|
||||
return [value, cleanName.charAt(0) + cleanName.slice(1).toLowerCase()];
|
||||
})
|
||||
) as Record<number, string>;
|
||||
|
||||
// URL-friendly names (lowercase, no TYPE_ prefix)
|
||||
export const nodeTypeUrlByValue: Record<number, string> = {
|
||||
[NodeType.TYPE_UNKNOWN]: 'unknown',
|
||||
[NodeType.TYPE_CHAT_NODE]: 'chat_node',
|
||||
[NodeType.TYPE_REPEATER]: 'repeater',
|
||||
[NodeType.TYPE_ROOM_SERVER]: 'room_server',
|
||||
[NodeType.TYPE_SENSOR]: 'sensor',
|
||||
};
|
||||
|
||||
export const nodeTypeValueByUrl: Record<string, number> = {
|
||||
'unknown': NodeType.TYPE_UNKNOWN,
|
||||
'chat_node': NodeType.TYPE_CHAT_NODE,
|
||||
'repeater': NodeType.TYPE_REPEATER,
|
||||
'room_server': NodeType.TYPE_ROOM_SERVER,
|
||||
'sensor': NodeType.TYPE_SENSOR,
|
||||
};
|
||||
|
||||
export const payloadUrlByValue: Record<number, string> = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => {
|
||||
const urlName = name.replace(/^PAYLOAD_TYPE_/, '').toLowerCase();
|
||||
return [value, urlName];
|
||||
})
|
||||
) as Record<number, string>;
|
||||
|
||||
export const payloadValueByUrl: Record<string, number> = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => {
|
||||
const urlName = name.replace(/^PAYLOAD_TYPE_/, '').toLowerCase();
|
||||
return [urlName, value];
|
||||
})
|
||||
) as Record<string, number>;
|
||||
|
||||
export const routeUrlByValue: Record<number, string> = Object.fromEntries(
|
||||
Object.entries(RouteType).map(([name, value]) => {
|
||||
const urlName = name.replace(/^ROUTE_TYPE_/, '').toLowerCase();
|
||||
return [value, urlName];
|
||||
})
|
||||
) as Record<number, string>;
|
||||
|
||||
export const routeValueByUrl: Record<string, number> = Object.fromEntries(
|
||||
Object.entries(RouteType).map(([name, value]) => {
|
||||
const urlName = name.replace(/^ROUTE_TYPE_/, '').toLowerCase();
|
||||
return [urlName, value];
|
||||
})
|
||||
) as Record<string, number>;
|
||||
|
||||
export const asHex = (value: Uint8Array): string => bytesToHex(value);
|
||||
|
||||
const payloadTypeList = [
|
||||
PayloadType.REQUEST,
|
||||
PayloadType.RESPONSE,
|
||||
PayloadType.TEXT,
|
||||
PayloadType.ACK,
|
||||
PayloadType.ADVERT,
|
||||
PayloadType.GROUP_TEXT,
|
||||
PayloadType.GROUP_DATA,
|
||||
PayloadType.ANON_REQ,
|
||||
PayloadType.PATH,
|
||||
PayloadType.TRACE,
|
||||
PayloadType.MULTIPART,
|
||||
PayloadType.CONTROL,
|
||||
PayloadType.RAW_CUSTOM,
|
||||
] as const;
|
||||
|
||||
const nodeTypeList = [
|
||||
NodeType.TYPE_CHAT_NODE,
|
||||
NodeType.TYPE_REPEATER,
|
||||
NodeType.TYPE_ROOM_SERVER,
|
||||
NodeType.TYPE_SENSOR,
|
||||
] as const;
|
||||
|
||||
const makePayloadBytes = (payloadType: number, seed: number): Uint8Array => {
|
||||
switch (payloadType) {
|
||||
case PayloadType.REQUEST:
|
||||
return new Uint8Array([0x00, 0x12, 0x34, 0x56, 0x78, seed]);
|
||||
case PayloadType.RESPONSE:
|
||||
return new Uint8Array([0x01, 0x78, 0x56, 0x34, 0x12, seed]);
|
||||
case PayloadType.TEXT:
|
||||
return new Uint8Array([0xa1, 0xb2, 0x11, 0x22, 0x54, 0x58, 0x54, seed]);
|
||||
case PayloadType.ACK:
|
||||
return new Uint8Array([0x03, seed]);
|
||||
case PayloadType.ADVERT:
|
||||
return new Uint8Array([0x04, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, seed]);
|
||||
case PayloadType.GROUP_TEXT:
|
||||
return new Uint8Array([0xc4, 0x23, 0x99, 0x44, 0x55, 0x66, seed]);
|
||||
case PayloadType.GROUP_DATA:
|
||||
return new Uint8Array([0x34, 0x98, 0x76, 0x10, seed, 0xee]);
|
||||
case PayloadType.ANON_REQ:
|
||||
return new Uint8Array([0xfe, ...Array.from({ length: 32 }, (_, i) => (seed + i * 5) & 0xff), 0x55, 0xaa, 0x42, seed]);
|
||||
case PayloadType.PATH:
|
||||
return new Uint8Array([0x08, 0x11, 0x22, 0x33, 0x44, 0x55, seed]);
|
||||
case PayloadType.TRACE:
|
||||
return new Uint8Array([0x12, 0x31, 0x51, seed]);
|
||||
case PayloadType.MULTIPART:
|
||||
return new Uint8Array([0x01, seed, 0x02, 0x03, 0x04]);
|
||||
case PayloadType.CONTROL:
|
||||
return new Uint8Array([0x90, 0x01, 0x02, seed]);
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
default:
|
||||
return new Uint8Array([0xde, 0xad, 0xbe, 0xef, seed]);
|
||||
}
|
||||
};
|
||||
|
||||
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
|
||||
switch (payloadType) {
|
||||
case PayloadType.REQUEST:
|
||||
return `request len=${payloadBytes.length}`;
|
||||
case PayloadType.RESPONSE:
|
||||
return `response len=${payloadBytes.length}`;
|
||||
case PayloadType.TEXT:
|
||||
if (decodedPayload && 'dstHash' in decodedPayload && 'srcHash' in decodedPayload) {
|
||||
return `text ${decodedPayload.srcHash}->${decodedPayload.dstHash}`;
|
||||
}
|
||||
return 'text message';
|
||||
case PayloadType.ACK:
|
||||
return 'acknowledgement';
|
||||
case PayloadType.ADVERT:
|
||||
return 'node advertisement';
|
||||
case PayloadType.GROUP_TEXT:
|
||||
if (decodedPayload && 'channelHash' in decodedPayload) {
|
||||
return `group channel=${decodedPayload.channelHash}`;
|
||||
}
|
||||
return `group text len=${payloadBytes.length}`;
|
||||
case PayloadType.GROUP_DATA:
|
||||
return `group data len=${payloadBytes.length}`;
|
||||
case PayloadType.ANON_REQ:
|
||||
if (decodedPayload && 'dstHash' in decodedPayload) {
|
||||
return `anon req dst=${decodedPayload.dstHash}`;
|
||||
}
|
||||
return 'anon request';
|
||||
case PayloadType.PATH:
|
||||
return `path len=${payloadBytes.length}`;
|
||||
case PayloadType.TRACE:
|
||||
return `trace len=${payloadBytes.length}`;
|
||||
case PayloadType.MULTIPART:
|
||||
return `multipart len=${payloadBytes.length}`;
|
||||
case PayloadType.CONTROL:
|
||||
if (decodedPayload && 'flags' in decodedPayload && typeof decodedPayload.flags === 'number') {
|
||||
return `control flags=0x${decodedPayload.flags.toString(16)}`;
|
||||
}
|
||||
return `control raw=${asHex(payloadBytes.slice(0, 4))}`;
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
default:
|
||||
return `raw=${asHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
|
||||
}
|
||||
};
|
||||
|
||||
const createMockRecord = (index: number): MeshCorePacketRecord => {
|
||||
const payloadType = payloadTypeList[index % payloadTypeList.length];
|
||||
const nodeType = nodeTypeList[index % nodeTypeList.length];
|
||||
const version = 1;
|
||||
const routeType = RouteType.FLOOD;
|
||||
const path = new Uint8Array([(0x11 + index) & 0xff, (0x90 + index) & 0xff, (0xa0 + index) & 0xff]);
|
||||
const payload = makePayloadBytes(payloadType, index);
|
||||
|
||||
const header = ((version & 0x03) << 6) | ((payloadType & 0x0f) << 2) | (routeType & 0x03);
|
||||
const pathLength = path.length & 0x3f;
|
||||
const raw = new Uint8Array([header, pathLength, ...path, ...payload]);
|
||||
|
||||
const packet = new Packet();
|
||||
packet.parse(raw);
|
||||
|
||||
let decodedPayload: Payload | undefined;
|
||||
try {
|
||||
decodedPayload = packet.decode();
|
||||
} catch {
|
||||
decodedPayload = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: new Date(Date.now() - index * 75_000),
|
||||
hash: asHex(packet.hash()),
|
||||
nodeType,
|
||||
payloadType,
|
||||
routeType,
|
||||
version,
|
||||
path,
|
||||
raw,
|
||||
decodedPayload,
|
||||
payloadSummary: summarizePayload(payloadType, decodedPayload, payload),
|
||||
};
|
||||
};
|
||||
|
||||
const createMockData = (count = 48): MeshCorePacketRecord[] => {
|
||||
return Array.from({ length: count }, (_, index) => createMockRecord(index)).sort(
|
||||
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => {
|
||||
return packets
|
||||
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
|
||||
.map((packet, index) => {
|
||||
const payload = packet.decodedPayload as Record<string, unknown> | undefined;
|
||||
const channel = (payload && typeof payload === 'object' && 'channelHash' in payload
|
||||
? (payload.channelHash as string)
|
||||
: 'general') as string;
|
||||
const sender = (payload && typeof payload === 'object' && 'srcHash' in payload
|
||||
? (payload.srcHash as string)
|
||||
: packet.path[0].toString(16).padStart(2, '0')) as string;
|
||||
|
||||
return {
|
||||
hash: packet.hash,
|
||||
timestamp: packet.timestamp,
|
||||
channel,
|
||||
sender,
|
||||
message: `Mock message #${index + 1} (${packet.payloadSummary})`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
|
||||
const byNode = new Map<string, MeshCoreNodePoint>();
|
||||
|
||||
packets.forEach((packet) => {
|
||||
const nodeId = packet.path[0].toString(16).padStart(2, '0');
|
||||
const existing = byNode.get(nodeId);
|
||||
|
||||
if (existing) {
|
||||
existing.packetCount += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const byte = packet.path[0];
|
||||
byNode.set(nodeId, {
|
||||
nodeId,
|
||||
nodeType: packet.nodeType,
|
||||
packetCount: 1,
|
||||
latitude: 52.0 + ((byte % 20) - 10) * 0.02,
|
||||
longitude: 5.0 + (((byte >> 1) % 20) - 10) * 0.03,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(byNode.values());
|
||||
};
|
||||
|
||||
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [packets, setPackets] = useState<MeshCorePacketRecord[]>(() => createMockData());
|
||||
const [streamReady, setStreamReady] = useState(false);
|
||||
|
||||
const stream = useMemo(() => new MeshCoreStream(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
stream.connect();
|
||||
|
||||
const unsubscribeState = stream.subscribeToState((state) => {
|
||||
setStreamReady(state.isConnected);
|
||||
});
|
||||
|
||||
const unsubscribePackets = stream.subscribe<MeshCoreMessage>(
|
||||
'meshcore/packet/#',
|
||||
(message) => {
|
||||
setPackets((prev) => {
|
||||
const packet: MeshCorePacketRecord = {
|
||||
timestamp: message.receivedAt,
|
||||
hash: message.hash,
|
||||
nodeType: 0, // Default; would be extracted from payload if needed
|
||||
payloadType: 0,
|
||||
routeType: 0,
|
||||
version: 1,
|
||||
path: new Uint8Array(),
|
||||
raw: message.raw,
|
||||
decodedPayload: message.decodedPayload,
|
||||
payloadSummary: '',
|
||||
radioName: message.radioName,
|
||||
};
|
||||
|
||||
// Extract details from raw packet
|
||||
try {
|
||||
const p = new Packet();
|
||||
p.parse(message.raw);
|
||||
packet.version = (message.raw[0] >> 6) & 0x03;
|
||||
packet.payloadType = (message.raw[0] >> 2) & 0x0f;
|
||||
packet.routeType = message.raw[0] & 0x03;
|
||||
|
||||
const pathLength = message.raw[1] & 0x3f;
|
||||
packet.path = message.raw.slice(2, 2 + pathLength);
|
||||
|
||||
// Summarize payload
|
||||
const payloadBytes = message.raw.slice(2 + pathLength);
|
||||
packet.payloadSummary = summarizePayload(packet.payloadType, message.decodedPayload, payloadBytes);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse packet:', error);
|
||||
packet.payloadSummary = `raw=${bytesToHex(packet.raw.slice(0, Math.min(6, packet.raw.length)))}`;
|
||||
}
|
||||
|
||||
// Add to front of list, keeping last 500 packets
|
||||
return [packet, ...prev].slice(0, 500);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeState();
|
||||
unsubscribePackets();
|
||||
stream.disconnect();
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
const groupChats = useMemo(() => toGroupChats(packets), [packets]);
|
||||
const mapPoints = useMemo(() => toMapPoints(packets), [packets]);
|
||||
|
||||
const value = useMemo<MeshCoreDataContextValue>(() => {
|
||||
return {
|
||||
packets,
|
||||
groupChats,
|
||||
mapPoints,
|
||||
streamReady,
|
||||
};
|
||||
}, [packets, groupChats, mapPoints, streamReady]);
|
||||
|
||||
return <MeshCoreDataContext.Provider value={value}>{children}</MeshCoreDataContext.Provider>;
|
||||
};
|
||||
Reference in New Issue
Block a user