More APRS enhancements
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 36s
Test and build / Build receiver (push) Failing after 36s

This commit is contained in:
2026-03-05 22:24:09 +01:00
parent 7a8d7b0275
commit e83df1c143
115 changed files with 3987 additions and 956 deletions

View File

@@ -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]);