403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
|
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
|
|
import LeakAddIcon from '@mui/icons-material/LeakAdd';
|
|
import PersonIcon from '@mui/icons-material/Person';
|
|
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
|
import ReplyIcon from '@mui/icons-material/Reply';
|
|
import RouteIcon from '@mui/icons-material/Route';
|
|
import SensorsIcon from '@mui/icons-material/Sensors';
|
|
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
|
|
import StorageIcon from '@mui/icons-material/Storage';
|
|
import WifiTetheringIcon from '@mui/icons-material/WifiTethering';
|
|
|
|
import { Packet } from '../../protocols/meshcore';
|
|
import { NodeType, PayloadType, RouteType } from '../../types/protocol/meshcore.types';
|
|
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
|
|
import API from '../../services/API';
|
|
import MeshCoreService from '../../services/MeshCoreService';
|
|
import MeshCoreStream from '../../services/MeshCoreStream';
|
|
|
|
import {
|
|
MeshCoreDataContext,
|
|
type MeshCoreDataContextValue,
|
|
type MeshCoreGroupChatRecord,
|
|
type MeshCoreNodePoint,
|
|
} from './MeshCoreContext';
|
|
|
|
export {
|
|
MeshCoreDataContext,
|
|
useMeshCoreData,
|
|
type MeshCoreDataContextValue,
|
|
type MeshCoreGroupChatRecord,
|
|
type MeshCoreNodePoint,
|
|
} from './MeshCoreContext';
|
|
|
|
export const payloadNameByValue: Record<number, string> = {
|
|
[PayloadType.REQUEST]: 'Request',
|
|
[PayloadType.RESPONSE]: 'Response',
|
|
[PayloadType.TEXT]: 'Private Message',
|
|
[PayloadType.ACK]: 'Acknowledgement',
|
|
[PayloadType.ADVERT]: 'Advertisement',
|
|
[PayloadType.GROUP_TEXT]: 'Group Message',
|
|
[PayloadType.GROUP_DATA]: 'Group Data',
|
|
[PayloadType.ANON_REQ]: 'Anonymous Request',
|
|
[PayloadType.PATH]: 'Path',
|
|
[PayloadType.TRACE]: 'Trace',
|
|
[PayloadType.MULTIPART]: 'Multipart',
|
|
[PayloadType.CONTROL]: 'Control',
|
|
[PayloadType.RAW_CUSTOM]: 'Custom',
|
|
};
|
|
|
|
export const nodeTypeNameByValue: Record<number, string> = {
|
|
[NodeType.TYPE_UNKNOWN]: 'Unknown',
|
|
[NodeType.TYPE_CHAT_NODE]: 'Chat Node',
|
|
[NodeType.TYPE_REPEATER]: 'Repeater',
|
|
[NodeType.TYPE_ROOM_SERVER]: 'Room Server',
|
|
[NodeType.TYPE_SENSOR]: 'Sensor',
|
|
};
|
|
|
|
export const routeTypeNameByValue: Record<number, string> = {
|
|
[RouteType.TRANSPORT_FLOOD]: 'Transport Flood',
|
|
[RouteType.FLOOD]: 'Flood',
|
|
[RouteType.DIRECT]: 'Direct',
|
|
[RouteType.TRANSPORT_DIRECT]: 'Transport Direct',
|
|
};
|
|
|
|
export const RouteTypeIcon: React.FC<{ routeType: number }> = ({ routeType }) => {
|
|
switch (routeType) {
|
|
case RouteType.TRANSPORT_FLOOD:
|
|
return <LeakAddIcon className="meshcore-route-icon meshcore-route-flood" titleAccess={routeTypeNameByValue[routeType]} />;
|
|
case RouteType.FLOOD:
|
|
return <LeakAddIcon className="meshcore-route-icon meshcore-route-flood" titleAccess={routeTypeNameByValue[routeType]} />;
|
|
case RouteType.DIRECT:
|
|
return <WifiTetheringIcon className="meshcore-route-icon meshcore-route-direct" titleAccess={routeTypeNameByValue[routeType]} />;
|
|
case RouteType.TRANSPORT_DIRECT:
|
|
return <WifiTetheringIcon className="meshcore-route-icon meshcore-route-direct" titleAccess={routeTypeNameByValue[routeType]} />;
|
|
default:
|
|
return <QuestionMarkIcon className="meshcore-route-icon" titleAccess="Unknown" />;
|
|
}
|
|
}
|
|
|
|
export const payloadValueByName = Object.fromEntries(
|
|
Object.entries(PayloadType).map(([name, value]) => [name, value])
|
|
) as Record<string, number>;
|
|
|
|
export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType }) => {
|
|
switch (payloadType) {
|
|
case PayloadType.REQUEST:
|
|
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.RESPONSE:
|
|
return <ArrowBackIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.TEXT:
|
|
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.ACK:
|
|
return <ReplyIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.ADVERT:
|
|
return <SignalCellularAltIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.GROUP_TEXT:
|
|
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.GROUP_DATA:
|
|
return <StorageIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.ANON_REQ:
|
|
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.PATH:
|
|
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.TRACE:
|
|
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.MULTIPART:
|
|
return <BrandingWatermarkIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.CONTROL:
|
|
return <SensorsIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
case PayloadType.RAW_CUSTOM:
|
|
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess={payloadNameByValue[payloadType]} />;
|
|
default:
|
|
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
|
|
}
|
|
};
|
|
|
|
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>;
|
|
|
|
const DISCARD_DUPLICATE_PATH_PACKETS = true;
|
|
|
|
const dedupeByHashAndPath = (packets: Packet[]): Packet[] => {
|
|
if (!DISCARD_DUPLICATE_PATH_PACKETS) {
|
|
return packets;
|
|
}
|
|
|
|
const sortedByReceiveTime = [...packets].sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
|
|
const seen = new Set<string>();
|
|
const deduped: Packet[] = [];
|
|
|
|
sortedByReceiveTime.forEach((packet) => {
|
|
const hash = packet.hash();
|
|
if (seen.has(hash)) {
|
|
return;
|
|
}
|
|
|
|
seen.add(hash);
|
|
deduped.push(packet);
|
|
});
|
|
|
|
return deduped;
|
|
};
|
|
|
|
// `payloadSummary` and its summarization logic were removed — summaries are rendered
|
|
// directly in the packet rows now. Keep path/key helpers above.
|
|
|
|
const toGroupChats = (packets: Packet[]): 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>;
|
|
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.length > 0 ? packet.path[0].toString(16).padStart(2, '0') : 'unknown')) as string;
|
|
|
|
return {
|
|
hash: packet.hash,
|
|
timestamp: packet.timestamp,
|
|
channel,
|
|
sender,
|
|
message: (() => {
|
|
const msg = (payload && typeof payload === 'object')
|
|
? ('message' in payload ? String((payload as any).message) : ('text' in payload ? String((payload as any).text) : undefined))
|
|
: undefined;
|
|
return msg ?? `Mock message #${index + 1}`;
|
|
})(),
|
|
};
|
|
});
|
|
};
|
|
|
|
const toMapPoints = (packets: Packet[]): MeshCoreNodePoint[] => {
|
|
const byNode = new Map<string, MeshCoreNodePoint>();
|
|
|
|
packets.forEach((packet) => {
|
|
if (!packet.path || packet.path.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const nodeId = packet.path[0].toString(16).padStart(2, '0');
|
|
const existing = byNode.get(nodeId);
|
|
|
|
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<Packet[]>([]);
|
|
const [streamReady, setStreamReady] = useState(false);
|
|
|
|
const stream = useMemo(() => new MeshCoreStream(false), []);
|
|
const meshCoreService = useMemo(() => new MeshCoreService(API), []);
|
|
|
|
// Populate stream key manager with known groups so live decryption works
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
try {
|
|
const groups = await meshCoreService.fetchGroups();
|
|
if (!mounted) return;
|
|
for (const g of groups) {
|
|
try {
|
|
stream.keyManager.addGroup(g.name, g.secret.toBytes());
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
})();
|
|
return () => { mounted = false; };
|
|
}, [meshCoreService, stream]);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const fetchPackets = async () => {
|
|
try {
|
|
const fetchedPackets = await meshCoreService.fetchPackets(undefined, undefined, undefined, stream.keyManager);
|
|
if (!isMounted) {
|
|
return;
|
|
}
|
|
|
|
setPackets((prev) => {
|
|
const merged = dedupeByHashAndPath([...fetchedPackets, ...prev]);
|
|
return merged
|
|
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
|
|
.slice(0, 500);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to fetch MeshCore packets:', error);
|
|
}
|
|
};
|
|
|
|
fetchPackets();
|
|
stream.connect();
|
|
|
|
const unsubscribeState = stream.subscribeToState((state) => {
|
|
setStreamReady(state.isConnected);
|
|
});
|
|
|
|
const unsubscribePackets = stream.subscribe<MeshCoreMessage>(
|
|
'meshcore/packet/#',
|
|
(message) => {
|
|
setPackets((prev) => {
|
|
const packet: Packet = {
|
|
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,
|
|
radioName: message.radioName,
|
|
snr: message.snr,
|
|
rssi: message.rssi,
|
|
};
|
|
|
|
// 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);
|
|
|
|
// If the stream provided a decrypted group message, attach it to the record
|
|
if (message.decryptedGroup) {
|
|
packet.decryptedGroup = message.decryptedGroup;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to parse packet:', error);
|
|
}
|
|
|
|
const merged = dedupeByHashAndPath([packet, ...prev]);
|
|
return merged
|
|
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
|
.slice(0, 500);
|
|
});
|
|
}
|
|
);
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
unsubscribeState();
|
|
unsubscribePackets();
|
|
stream.disconnect();
|
|
};
|
|
}, [stream, meshCoreService]);
|
|
|
|
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>;
|
|
};
|