forked from iarv/Remote-Terminal-for-MeshCore
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
|
|
|
|
import type { RawPacket } from '../types';
|
|
import { getRawPacketObservationKey } from './rawPacketIdentity';
|
|
|
|
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
|
|
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
|
|
|
|
export const RAW_PACKET_STATS_WINDOW_SECONDS: Record<
|
|
Exclude<RawPacketStatsWindow, 'session'>,
|
|
number
|
|
> = {
|
|
'1m': 60,
|
|
'5m': 5 * 60,
|
|
'10m': 10 * 60,
|
|
'30m': 30 * 60,
|
|
};
|
|
|
|
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
|
|
|
|
const KNOWN_PAYLOAD_TYPES = [
|
|
'Advert',
|
|
'GroupText',
|
|
'TextMessage',
|
|
'Ack',
|
|
'Request',
|
|
'Response',
|
|
'Trace',
|
|
'Path',
|
|
'Control',
|
|
'Unknown',
|
|
] as const;
|
|
|
|
const KNOWN_ROUTE_TYPES = [
|
|
'Flood',
|
|
'Direct',
|
|
'TransportFlood',
|
|
'TransportDirect',
|
|
'Unknown',
|
|
] as const;
|
|
|
|
export interface RawPacketStatsObservation {
|
|
observationKey: string;
|
|
timestamp: number;
|
|
payloadType: string;
|
|
routeType: string;
|
|
decrypted: boolean;
|
|
rssi: number | null;
|
|
snr: number | null;
|
|
sourceKey: string | null;
|
|
sourceLabel: string | null;
|
|
pathTokenCount: number;
|
|
pathSignature: string | null;
|
|
}
|
|
|
|
export interface RawPacketStatsSessionState {
|
|
sessionStartedAt: number;
|
|
totalObservedPackets: number;
|
|
trimmedObservationCount: number;
|
|
observations: RawPacketStatsObservation[];
|
|
}
|
|
|
|
export interface RankedPacketStat {
|
|
label: string;
|
|
count: number;
|
|
share: number;
|
|
}
|
|
|
|
export interface NeighborStat {
|
|
key: string;
|
|
label: string;
|
|
count: number;
|
|
bestRssi: number | null;
|
|
lastSeen: number;
|
|
}
|
|
|
|
export interface PacketTimelineBin {
|
|
label: string;
|
|
total: number;
|
|
countsByType: Record<string, number>;
|
|
}
|
|
|
|
export interface RawPacketStatsSnapshot {
|
|
window: RawPacketStatsWindow;
|
|
nowSec: number;
|
|
packets: RawPacketStatsObservation[];
|
|
packetCount: number;
|
|
packetsPerMinute: number;
|
|
uniqueSources: number;
|
|
decryptedCount: number;
|
|
undecryptedCount: number;
|
|
decryptRate: number;
|
|
pathBearingCount: number;
|
|
pathBearingRate: number;
|
|
distinctPaths: number;
|
|
payloadBreakdown: RankedPacketStat[];
|
|
routeBreakdown: RankedPacketStat[];
|
|
topPacketTypes: RankedPacketStat[];
|
|
hopProfile: RankedPacketStat[];
|
|
strongestNeighbors: NeighborStat[];
|
|
mostActiveNeighbors: NeighborStat[];
|
|
newestNeighbors: NeighborStat[];
|
|
averageRssi: number | null;
|
|
medianRssi: number | null;
|
|
bestRssi: number | null;
|
|
rssiBuckets: RankedPacketStat[];
|
|
strongestPacketSourceKey: string | null;
|
|
strongestPacketSourceLabel: string | null;
|
|
strongestPacketPayloadType: string | null;
|
|
coverageSeconds: number;
|
|
windowFullyCovered: boolean;
|
|
oldestStoredTimestamp: number | null;
|
|
timeline: PacketTimelineBin[];
|
|
}
|
|
|
|
function toSourceLabel(sourceKey: string): string {
|
|
if (sourceKey.startsWith('name:')) {
|
|
return sourceKey.slice(5);
|
|
}
|
|
return sourceKey.slice(0, 12).toUpperCase();
|
|
}
|
|
|
|
function getPathTokens(decoded: ReturnType<typeof MeshCoreDecoder.decode>): string[] {
|
|
const tracePayload =
|
|
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
|
? (decoded.payload.decoded as { pathHashes?: string[] })
|
|
: null;
|
|
return tracePayload?.pathHashes || decoded.path || [];
|
|
}
|
|
|
|
function getSourceInfo(
|
|
packet: RawPacket,
|
|
decoded: ReturnType<typeof MeshCoreDecoder.decode>
|
|
): Pick<RawPacketStatsObservation, 'sourceKey' | 'sourceLabel'> {
|
|
if (!decoded.isValid || !decoded.payload.decoded) {
|
|
const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
|
|
if (fallbackContactKey) {
|
|
return {
|
|
sourceKey: fallbackContactKey,
|
|
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey),
|
|
};
|
|
}
|
|
if (packet.decrypted_info?.sender) {
|
|
return {
|
|
sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`,
|
|
sourceLabel: packet.decrypted_info.sender,
|
|
};
|
|
}
|
|
return { sourceKey: null, sourceLabel: null };
|
|
}
|
|
|
|
switch (decoded.payloadType) {
|
|
case PayloadType.Advert: {
|
|
const publicKey = (decoded.payload.decoded as { publicKey?: string }).publicKey;
|
|
if (!publicKey) return { sourceKey: null, sourceLabel: null };
|
|
return {
|
|
sourceKey: publicKey.toUpperCase(),
|
|
sourceLabel: publicKey.slice(0, 12).toUpperCase(),
|
|
};
|
|
}
|
|
case PayloadType.TextMessage:
|
|
case PayloadType.Request:
|
|
case PayloadType.Response: {
|
|
const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash;
|
|
if (!sourceHash) return { sourceKey: null, sourceLabel: null };
|
|
return {
|
|
sourceKey: sourceHash.toUpperCase(),
|
|
sourceLabel: sourceHash.toUpperCase(),
|
|
};
|
|
}
|
|
case PayloadType.GroupText: {
|
|
const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
|
|
if (contactKey) {
|
|
return {
|
|
sourceKey: contactKey,
|
|
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey),
|
|
};
|
|
}
|
|
if (packet.decrypted_info?.sender) {
|
|
return {
|
|
sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`,
|
|
sourceLabel: packet.decrypted_info.sender,
|
|
};
|
|
}
|
|
return { sourceKey: null, sourceLabel: null };
|
|
}
|
|
case PayloadType.AnonRequest: {
|
|
const senderPublicKey = (decoded.payload.decoded as { senderPublicKey?: string })
|
|
.senderPublicKey;
|
|
if (!senderPublicKey) return { sourceKey: null, sourceLabel: null };
|
|
return {
|
|
sourceKey: senderPublicKey.toUpperCase(),
|
|
sourceLabel: senderPublicKey.slice(0, 12).toUpperCase(),
|
|
};
|
|
}
|
|
default: {
|
|
const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
|
|
if (fallbackContactKey) {
|
|
return {
|
|
sourceKey: fallbackContactKey,
|
|
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey),
|
|
};
|
|
}
|
|
return { sourceKey: null, sourceLabel: null };
|
|
}
|
|
}
|
|
}
|
|
|
|
export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObservation {
|
|
try {
|
|
const decoded = MeshCoreDecoder.decode(packet.data);
|
|
const pathTokens = decoded.isValid ? getPathTokens(decoded) : [];
|
|
const payloadType = decoded.isValid
|
|
? Utils.getPayloadTypeName(decoded.payloadType)
|
|
: packet.payload_type;
|
|
const routeType = decoded.isValid ? Utils.getRouteTypeName(decoded.routeType) : 'Unknown';
|
|
const sourceInfo = getSourceInfo(packet, decoded);
|
|
|
|
return {
|
|
observationKey: getRawPacketObservationKey(packet),
|
|
timestamp: packet.timestamp,
|
|
payloadType,
|
|
routeType,
|
|
decrypted: packet.decrypted,
|
|
rssi: packet.rssi,
|
|
snr: packet.snr,
|
|
sourceKey: sourceInfo.sourceKey,
|
|
sourceLabel: sourceInfo.sourceLabel,
|
|
pathTokenCount: pathTokens.length,
|
|
pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null,
|
|
};
|
|
} catch {
|
|
return {
|
|
observationKey: getRawPacketObservationKey(packet),
|
|
timestamp: packet.timestamp,
|
|
payloadType: packet.payload_type,
|
|
routeType: 'Unknown',
|
|
decrypted: packet.decrypted,
|
|
rssi: packet.rssi,
|
|
snr: packet.snr,
|
|
sourceKey: null,
|
|
sourceLabel: null,
|
|
pathTokenCount: 0,
|
|
pathSignature: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
function share(count: number, total: number): number {
|
|
if (total <= 0) return 0;
|
|
return count / total;
|
|
}
|
|
|
|
function createCountsMap(labels: readonly string[]): Map<string, number> {
|
|
return new Map(labels.map((label) => [label, 0]));
|
|
}
|
|
|
|
function rankedBreakdown(counts: Map<string, number>, total: number): RankedPacketStat[] {
|
|
return Array.from(counts.entries())
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.map(([label, count]) => ({ label, count, share: share(count, total) }));
|
|
}
|
|
|
|
function median(values: number[]): number | null {
|
|
if (values.length === 0) return null;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
if (sorted.length % 2 === 1) {
|
|
return sorted[mid];
|
|
}
|
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
}
|
|
|
|
function formatTimelineLabel(timestamp: number): string {
|
|
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
export function buildRawPacketStatsSnapshot(
|
|
session: RawPacketStatsSessionState,
|
|
window: RawPacketStatsWindow,
|
|
nowSec: number = Math.floor(Date.now() / 1000)
|
|
): RawPacketStatsSnapshot {
|
|
const sessionStartedSec = Math.floor(session.sessionStartedAt / 1000);
|
|
const windowSeconds = window === 'session' ? null : RAW_PACKET_STATS_WINDOW_SECONDS[window];
|
|
const windowStart = windowSeconds === null ? sessionStartedSec : nowSec - windowSeconds;
|
|
const packets = session.observations.filter((packet) => packet.timestamp >= windowStart);
|
|
const packetCount = packets.length;
|
|
const uniqueSources = new Set(packets.map((packet) => packet.sourceKey).filter(Boolean)).size;
|
|
const decryptedCount = packets.filter((packet) => packet.decrypted).length;
|
|
const undecryptedCount = packetCount - decryptedCount;
|
|
const pathBearingCount = packets.filter((packet) => packet.pathTokenCount > 0).length;
|
|
const distinctPaths = new Set(
|
|
packets.map((packet) => packet.pathSignature).filter((value): value is string => Boolean(value))
|
|
).size;
|
|
const effectiveCoverageSeconds =
|
|
windowSeconds ?? Math.max(1, nowSec - Math.min(sessionStartedSec, nowSec));
|
|
const packetsPerMinute = packetCount / Math.max(effectiveCoverageSeconds / 60, 1 / 60);
|
|
|
|
const payloadCounts = createCountsMap(KNOWN_PAYLOAD_TYPES);
|
|
const routeCounts = createCountsMap(KNOWN_ROUTE_TYPES);
|
|
const hopCounts = new Map<string, number>([
|
|
['Direct', 0],
|
|
['1 hop', 0],
|
|
['2+ hops', 0],
|
|
]);
|
|
const neighborMap = new Map<string, NeighborStat>();
|
|
const rssiValues: number[] = [];
|
|
const rssiBucketCounts = new Map<string, number>([
|
|
['Strong (>-70 dBm)', 0],
|
|
['Okay (-70 to -85 dBm)', 0],
|
|
['Weak (<-85 dBm)', 0],
|
|
]);
|
|
|
|
let strongestPacket: RawPacketStatsObservation | null = null;
|
|
|
|
for (const packet of packets) {
|
|
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
|
|
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
|
|
|
|
if (packet.pathTokenCount <= 0) {
|
|
hopCounts.set('Direct', (hopCounts.get('Direct') ?? 0) + 1);
|
|
} else if (packet.pathTokenCount === 1) {
|
|
hopCounts.set('1 hop', (hopCounts.get('1 hop') ?? 0) + 1);
|
|
} else {
|
|
hopCounts.set('2+ hops', (hopCounts.get('2+ hops') ?? 0) + 1);
|
|
}
|
|
|
|
if (packet.sourceKey && packet.sourceLabel) {
|
|
const existing = neighborMap.get(packet.sourceKey);
|
|
if (!existing) {
|
|
neighborMap.set(packet.sourceKey, {
|
|
key: packet.sourceKey,
|
|
label: packet.sourceLabel,
|
|
count: 1,
|
|
bestRssi: packet.rssi,
|
|
lastSeen: packet.timestamp,
|
|
});
|
|
} else {
|
|
existing.count += 1;
|
|
existing.lastSeen = Math.max(existing.lastSeen, packet.timestamp);
|
|
if (
|
|
packet.rssi !== null &&
|
|
(existing.bestRssi === null || packet.rssi > existing.bestRssi)
|
|
) {
|
|
existing.bestRssi = packet.rssi;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (packet.rssi !== null) {
|
|
rssiValues.push(packet.rssi);
|
|
if (packet.rssi > -70) {
|
|
rssiBucketCounts.set(
|
|
'Strong (>-70 dBm)',
|
|
(rssiBucketCounts.get('Strong (>-70 dBm)') ?? 0) + 1
|
|
);
|
|
} else if (packet.rssi >= -85) {
|
|
rssiBucketCounts.set(
|
|
'Okay (-70 to -85 dBm)',
|
|
(rssiBucketCounts.get('Okay (-70 to -85 dBm)') ?? 0) + 1
|
|
);
|
|
} else {
|
|
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
|
|
}
|
|
|
|
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
|
|
strongestPacket = packet;
|
|
}
|
|
}
|
|
}
|
|
|
|
const averageRssi =
|
|
rssiValues.length > 0
|
|
? rssiValues.reduce((sum, value) => sum + value, 0) / rssiValues.length
|
|
: null;
|
|
const bestRssi = rssiValues.length > 0 ? Math.max(...rssiValues) : null;
|
|
const medianRssi = median(rssiValues);
|
|
const neighbors = Array.from(neighborMap.values());
|
|
const strongestNeighbors = [...neighbors]
|
|
.filter((neighbor) => neighbor.bestRssi !== null)
|
|
.sort(
|
|
(a, b) =>
|
|
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
|
|
b.count - a.count ||
|
|
a.label.localeCompare(b.label)
|
|
)
|
|
.slice(0, 5);
|
|
const mostActiveNeighbors = [...neighbors]
|
|
.sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label))
|
|
.slice(0, 5);
|
|
const newestNeighbors = [...neighbors]
|
|
.sort((a, b) => b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label))
|
|
.slice(0, 5);
|
|
|
|
const oldestStoredTimestamp = session.observations[0]?.timestamp ?? null;
|
|
const detailedCoverageStart =
|
|
session.trimmedObservationCount > 0 ? (oldestStoredTimestamp ?? nowSec) : sessionStartedSec;
|
|
const windowFullyCovered =
|
|
window === 'session'
|
|
? session.trimmedObservationCount === 0
|
|
: detailedCoverageStart <= windowStart;
|
|
const coverageStart = Math.max(windowStart, detailedCoverageStart);
|
|
const coverageSeconds =
|
|
window === 'session'
|
|
? Math.max(1, nowSec - detailedCoverageStart)
|
|
: Math.max(1, nowSec - coverageStart);
|
|
|
|
const timelineSpanSeconds = Math.max(
|
|
windowSeconds ?? Math.max(60, nowSec - sessionStartedSec),
|
|
60
|
|
);
|
|
const timelineBinCount = 10;
|
|
const binWidth = Math.max(1, timelineSpanSeconds / timelineBinCount);
|
|
const timeline = Array.from({ length: timelineBinCount }, (_, index) => {
|
|
const start = Math.floor(windowStart + index * binWidth);
|
|
return {
|
|
label: formatTimelineLabel(start),
|
|
total: 0,
|
|
countsByType: {} as Record<string, number>,
|
|
};
|
|
});
|
|
|
|
for (const packet of packets) {
|
|
const rawIndex = Math.floor((packet.timestamp - windowStart) / binWidth);
|
|
const index = Math.max(0, Math.min(timelineBinCount - 1, rawIndex));
|
|
const bin = timeline[index];
|
|
bin.total += 1;
|
|
bin.countsByType[packet.payloadType] = (bin.countsByType[packet.payloadType] ?? 0) + 1;
|
|
}
|
|
|
|
return {
|
|
window,
|
|
nowSec,
|
|
packets,
|
|
packetCount,
|
|
packetsPerMinute,
|
|
uniqueSources,
|
|
decryptedCount,
|
|
undecryptedCount,
|
|
decryptRate: share(decryptedCount, packetCount),
|
|
pathBearingCount,
|
|
pathBearingRate: share(pathBearingCount, packetCount),
|
|
distinctPaths,
|
|
payloadBreakdown: rankedBreakdown(payloadCounts, packetCount),
|
|
routeBreakdown: rankedBreakdown(routeCounts, packetCount),
|
|
topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5),
|
|
hopProfile: rankedBreakdown(hopCounts, packetCount),
|
|
strongestNeighbors,
|
|
mostActiveNeighbors,
|
|
newestNeighbors,
|
|
averageRssi,
|
|
medianRssi,
|
|
bestRssi,
|
|
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
|
|
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
|
|
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
|
|
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
|
|
coverageSeconds,
|
|
windowFullyCovered,
|
|
oldestStoredTimestamp,
|
|
timeline,
|
|
};
|
|
}
|