More APRS enhancements
This commit is contained in:
@@ -1,11 +1,24 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
|
||||
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 { 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 type { Payload, AdvertPayload } from '../../protocols/meshcore.types';
|
||||
import API from '../../services/API';
|
||||
import MeshCoreServiceImpl from '../../services/MeshCoreService';
|
||||
import { base64ToBytes } from '../../util';
|
||||
|
||||
import {
|
||||
MeshCoreDataContext,
|
||||
@@ -40,6 +53,38 @@ 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={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.RESPONSE:
|
||||
return <ArrowBackIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.TEXT:
|
||||
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.ACK:
|
||||
return <ReplyIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.ADVERT:
|
||||
return <SignalCellularAltIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.GROUP_TEXT:
|
||||
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.GROUP_DATA:
|
||||
return <StorageIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.ANON_REQ:
|
||||
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.PATH:
|
||||
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.TRACE:
|
||||
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.MULTIPART:
|
||||
return <BrandingWatermarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.CONTROL:
|
||||
return <SensorsIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[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>;
|
||||
@@ -116,61 +161,50 @@ export const routeValueByUrl: Record<string, number> = Object.fromEntries(
|
||||
})
|
||||
) as Record<string, number>;
|
||||
|
||||
export const asHex = (value: Uint8Array): string => bytesToHex(value);
|
||||
const DISCARD_DUPLICATE_PATH_PACKETS = true;
|
||||
|
||||
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 getPacketPathKey = (raw: Uint8Array): string => {
|
||||
if (raw.length < 2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const pathField = raw[1];
|
||||
const hashSize = (pathField >> 6) + 1;
|
||||
const hashCount = pathField & 0x3f;
|
||||
|
||||
if (hashCount === 0 || hashSize === 4) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const pathByteLength = hashCount * hashSize;
|
||||
const availablePathBytes = Math.min(pathByteLength, Math.max(raw.length - 2, 0));
|
||||
if (availablePathBytes <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return bytesToHex(raw.slice(2, 2 + availablePathBytes));
|
||||
};
|
||||
|
||||
const dedupeByHashAndPath = (packets: MeshCorePacketRecord[]): MeshCorePacketRecord[] => {
|
||||
if (!DISCARD_DUPLICATE_PATH_PACKETS) {
|
||||
return packets;
|
||||
}
|
||||
|
||||
const sortedByReceiveTime = [...packets].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
const seen = new Set<string>();
|
||||
const deduped: MeshCorePacketRecord[] = [];
|
||||
|
||||
sortedByReceiveTime.forEach((packet) => {
|
||||
const signature = `${packet.hash}:${getPacketPathKey(packet.raw)}`;
|
||||
if (seen.has(signature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(signature);
|
||||
deduped.push(packet);
|
||||
});
|
||||
|
||||
return deduped;
|
||||
};
|
||||
|
||||
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
|
||||
@@ -187,7 +221,12 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
|
||||
case PayloadType.ACK:
|
||||
return 'acknowledgement';
|
||||
case PayloadType.ADVERT:
|
||||
return 'node advertisement';
|
||||
const advert = decodedPayload as AdvertPayload;
|
||||
console.log('advert', advert);
|
||||
if (advert && decodedPayload && 'appdata' in decodedPayload && advert.appdata && 'name' in advert.appdata) {
|
||||
return `advertisement: ${advert.appdata.name}`;
|
||||
}
|
||||
return 'advertisement';
|
||||
case PayloadType.GROUP_TEXT:
|
||||
if (decodedPayload && 'channelHash' in decodedPayload) {
|
||||
return `group channel=${decodedPayload.channelHash}`;
|
||||
@@ -210,55 +249,13 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
|
||||
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))}`;
|
||||
return `control raw=${bytesToHex(payloadBytes.slice(0, 4))}`;
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
default:
|
||||
return `raw=${asHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
|
||||
return `raw=${bytesToHex(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)
|
||||
@@ -307,12 +304,63 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
|
||||
};
|
||||
|
||||
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [packets, setPackets] = useState<MeshCorePacketRecord[]>(() => createMockData());
|
||||
const [packets, setPackets] = useState<MeshCorePacketRecord[]>([]);
|
||||
const [streamReady, setStreamReady] = useState(false);
|
||||
|
||||
const stream = useMemo(() => new MeshCoreStream(false), []);
|
||||
const meshCoreService = useMemo(() => new MeshCoreServiceImpl(API), []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchPackets = async () => {
|
||||
try {
|
||||
const fetchedPackets = await meshCoreService.fetchPackets();
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records: MeshCorePacketRecord[] = fetchedPackets.map((packet) => {
|
||||
const raw = base64ToBytes(packet.raw);
|
||||
|
||||
let decodedPayload: Payload | undefined;
|
||||
try {
|
||||
const p = new Packet();
|
||||
p.parse(raw);
|
||||
decodedPayload = p.decode();
|
||||
} catch {
|
||||
decodedPayload = undefined;
|
||||
}
|
||||
|
||||
const pathLength = raw[1] & 0x3f;
|
||||
const payloadBytes = raw.slice(2 + pathLength);
|
||||
|
||||
return {
|
||||
timestamp: new Date(packet.received_at),
|
||||
hash: packet.hash,
|
||||
nodeType: packet.payload_type === PayloadType.ADVERT ? NodeType.TYPE_UNKNOWN : 0,
|
||||
payloadType: packet.payload_type,
|
||||
routeType: packet.route_type,
|
||||
version: packet.version,
|
||||
path: raw.slice(2, 2 + pathLength),
|
||||
raw,
|
||||
decodedPayload,
|
||||
payloadSummary: summarizePayload(packet.payload_type, decodedPayload, payloadBytes),
|
||||
};
|
||||
});
|
||||
|
||||
setPackets((prev) => {
|
||||
const merged = dedupeByHashAndPath([...records, ...prev]);
|
||||
return merged
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, 500);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MeshCore packets:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPackets();
|
||||
stream.connect();
|
||||
|
||||
const unsubscribeState = stream.subscribeToState((state) => {
|
||||
@@ -356,18 +404,21 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
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);
|
||||
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]);
|
||||
}, [stream, meshCoreService]);
|
||||
|
||||
const groupChats = useMemo(() => toGroupChats(packets), [packets]);
|
||||
const mapPoints = useMemo(() => toMapPoints(packets), [packets]);
|
||||
|
||||
Reference in New Issue
Block a user