mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-04 08:52:03 +02:00
Add fancy metrics view for packet feed. Closes #75.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user