Files
Remote-Terminal-for-MeshCore/frontend/src/utils/pathUtils.ts
2026-03-09 10:26:01 -07:00

564 lines
16 KiB
TypeScript

import type { Contact, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
const MAX_PATH_BYTES = 64;
export interface PathHop {
prefix: string; // Hex hop identifier (e.g., "1A" for 1-byte, "1A2B" for 2-byte)
matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous)
distanceFromPrev: number | null; // km from previous hop
}
export interface ResolvedPath {
sender: { name: string; prefix: string; lat: number | null; lon: number | null };
hops: PathHop[];
receiver: {
name: string;
prefix: string;
lat: number | null;
lon: number | null;
publicKey: string | null;
};
totalDistances: number[] | null; // Single-element array with sum of unambiguous distances
/** True if path has any gaps (unknown, ambiguous, or missing location hops) */
hasGaps: boolean;
}
export interface SenderInfo {
name: string;
publicKeyOrPrefix: string;
lat: number | null;
lon: number | null;
pathHashMode?: number | null;
}
export interface EffectiveContactRoute {
path: string | null;
pathLen: number;
pathHashMode: number;
forced: boolean;
}
function normalizePathHashMode(mode: number | null | undefined): number | null {
if (mode == null || !Number.isInteger(mode) || mode < 0 || mode > 2) {
return null;
}
return mode;
}
function inferPathHashMode(
path: string | null | undefined,
hopCount?: number | null
): number | null {
if (!path || path.length === 0 || hopCount == null || hopCount <= 0) {
return null;
}
const charsPerHop = path.length / hopCount;
if (
charsPerHop < 2 ||
charsPerHop > 6 ||
charsPerHop % 2 !== 0 ||
charsPerHop * hopCount !== path.length
) {
return null;
}
return charsPerHop / 2 - 1;
}
function formatEndpointPrefix(key: string | null | undefined, pathHashMode: number | null): string {
if (!key) {
return '??';
}
const normalized = key.toUpperCase();
const hashMode = normalizePathHashMode(pathHashMode) ?? 0;
const chars = (hashMode + 1) * 2;
return normalized.slice(0, Math.min(chars, normalized.length));
}
/**
* Split hex path string into per-hop chunks.
*
* When hopCount is provided (from path_len metadata), the bytes-per-hop is
* derived from the hex length divided by the hop count. This correctly handles
* multi-byte hop identifiers (1, 2, or 3 bytes per hop).
*
* Falls back to 2-char (1-byte) chunks when hopCount is missing or doesn't
* divide evenly — matching legacy behavior.
*/
export function parsePathHops(path: string | null | undefined, hopCount?: number | null): string[] {
if (!path || path.length === 0) {
return [];
}
const normalized = path.toUpperCase();
// Derive chars-per-hop from metadata when available
let charsPerHop = 2; // default: 1-byte hops
if (hopCount && hopCount > 0) {
const derived = normalized.length / hopCount;
// Accept only valid even widths (2, 4, 6) that divide evenly
if (derived >= 2 && derived % 2 === 0 && derived * hopCount === normalized.length) {
charsPerHop = derived;
}
}
const hops: string[] = [];
for (let i = 0; i + charsPerHop <= normalized.length; i += charsPerHop) {
hops.push(normalized.slice(i, i + charsPerHop));
}
return hops;
}
export function hasRoutingOverride(contact: Contact): boolean {
return contact.route_override_len !== null && contact.route_override_len !== undefined;
}
export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute {
const forced = hasRoutingOverride(contact);
const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len;
const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? '');
let pathHashMode = forced
? (contact.route_override_hash_mode ?? null)
: (contact.out_path_hash_mode ?? null);
if (pathLen === -1) {
pathHashMode = -1;
} else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) {
pathHashMode = inferPathHashMode(path, pathLen) ?? 0;
}
return {
path: path || null,
pathLen,
pathHashMode,
forced,
};
}
export function formatRouteLabel(pathLen: number, capitalize: boolean = false): string {
const label =
pathLen === -1
? 'flood'
: pathLen === 0
? 'direct'
: `${pathLen} hop${pathLen === 1 ? '' : 's'}`;
return capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label;
}
export function formatRoutingOverrideInput(contact: Contact): string {
if (!hasRoutingOverride(contact)) {
return '';
}
if (contact.route_override_len === -1) {
return '-1';
}
if (contact.route_override_len === 0) {
return '0';
}
return parsePathHops(contact.route_override_path, contact.route_override_len)
.map((hop) => hop.toLowerCase())
.join(',');
}
/**
* Extract the payload portion from a raw packet hex string using firmware-equivalent
* path-byte validation. Returns null for malformed or payload-less packets.
*/
export function extractPacketPayloadHex(packetHex: string): string | null {
if (packetHex.length < 4) {
return null;
}
try {
const normalized = packetHex.toUpperCase();
const header = parseInt(normalized.slice(0, 2), 16);
const routeType = header & 0x03;
let offset = 2;
if (routeType === 0x00 || routeType === 0x03) {
if (normalized.length < offset + 8) {
return null;
}
offset += 8;
}
if (normalized.length < offset + 2) {
return null;
}
const pathByte = parseInt(normalized.slice(offset, offset + 2), 16);
offset += 2;
const hashMode = (pathByte >> 6) & 0x03;
if (hashMode === 0x03) {
return null;
}
const hopCount = pathByte & 0x3f;
const hashSize = hashMode + 1;
const pathByteLen = hopCount * hashSize;
if (pathByteLen > MAX_PATH_BYTES) {
return null;
}
const pathHexChars = pathByteLen * 2;
if (normalized.length < offset + pathHexChars) {
return null;
}
offset += pathHexChars;
if (offset >= normalized.length) {
return null;
}
return normalized.slice(offset);
} catch {
return null;
}
}
/**
* Find contacts matching first 2 chars of public key (repeaters only for intermediate hops)
*/
export function findContactsByPrefix(
prefix: string,
contacts: Contact[],
repeatersOnly: boolean = true
): Contact[] {
const normalizedPrefix = prefix.toUpperCase();
return contacts.filter((c) => {
if (repeatersOnly && c.type !== CONTACT_TYPE_REPEATER) {
return false;
}
return c.public_key.toUpperCase().startsWith(normalizedPrefix);
});
}
/**
* Calculate distance between two points using Haversine formula
* @returns Distance in km, or null if coordinates are missing
*/
export function calculateDistance(
lat1: number | null,
lon1: number | null,
lat2: number | null,
lon2: number | null
): number | null {
if (lat1 === null || lon1 === null || lat2 === null || lon2 === null) {
return null;
}
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
/**
* Check if coordinates represent a valid location
* Returns false for null or (0, 0) which indicates unset location
*/
export function isValidLocation(lat: number | null, lon: number | null): boolean {
if (lat === null || lon === null) {
return false;
}
// (0, 0) is in the Atlantic Ocean - treat as unset
if (lat === 0 && lon === 0) {
return false;
}
return true;
}
/**
* Format distance in human-readable form (m or km)
*/
export function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
}
return `${km.toFixed(1)}km`;
}
/**
* Sort contacts by distance from a reference point
* Contacts without location are placed at the end
*/
function sortContactsByDistance(
contacts: Contact[],
fromLat: number | null,
fromLon: number | null
): Contact[] {
if (fromLat === null || fromLon === null) {
return contacts;
}
return [...contacts].sort((a, b) => {
const distA = calculateDistance(fromLat, fromLon, a.lat, a.lon);
const distB = calculateDistance(fromLat, fromLon, b.lat, b.lon);
// Null distances go to the end
if (distA === null && distB === null) return 0;
if (distA === null) return 1;
if (distB === null) return -1;
return distA - distB;
});
}
/**
* Get hop count from path, using explicit metadata when available.
*/
function getHopCount(path: string | null | undefined, hopCount?: number | null): number {
if (hopCount != null && hopCount >= 0) {
return hopCount;
}
if (!path || path.length === 0) {
return 0;
}
// Legacy fallback: assume 1-byte (2 hex chars) per hop
return Math.floor(path.length / 2);
}
/**
* Format hop counts from multiple paths for display.
* Returns something like "d/1/3/3" for direct, 1-hop, 3-hop, 3-hop paths.
* Returns null if no paths or only direct.
*/
export function formatHopCounts(paths: MessagePath[] | null | undefined): {
display: string;
allDirect: boolean;
hasMultiple: boolean;
} {
if (!paths || paths.length === 0) {
return { display: '', allDirect: true, hasMultiple: false };
}
// Get hop counts for all paths and sort ascending
const hopCounts = paths.map((p) => getHopCount(p.path, p.path_len)).sort((a, b) => a - b);
const allDirect = hopCounts.every((h) => h === 0);
const hasMultiple = paths.length > 1;
// Format: "d" for 0, numbers for others
const parts = hopCounts.map((h) => (h === 0 ? 'd' : h.toString()));
const display = parts.join('/');
return { display, allDirect, hasMultiple };
}
/**
* Build complete path resolution with sender, hops, and receiver
*/
export function resolvePath(
path: string | null | undefined,
sender: SenderInfo,
contacts: Contact[],
config: RadioConfig | null,
hopCount?: number | null
): ResolvedPath {
const hopPrefixes = parsePathHops(path, hopCount);
const inferredPathHashMode = inferPathHashMode(path, hopCount);
// Build sender info
const senderPrefix = formatEndpointPrefix(
sender.publicKeyOrPrefix,
normalizePathHashMode(sender.pathHashMode) ?? inferredPathHashMode
);
const resolvedSender = {
name: sender.name,
prefix: senderPrefix,
lat: sender.lat,
lon: sender.lon,
};
// Build receiver info from radio config
const receiverPrefix = formatEndpointPrefix(
config?.public_key,
normalizePathHashMode(config?.path_hash_mode) ?? inferredPathHashMode
);
const resolvedReceiver = {
name: config?.name || 'Unknown',
prefix: receiverPrefix,
lat: config?.lat ?? null,
lon: config?.lon ?? null,
publicKey: config?.public_key ?? null,
};
// Build hops
const hops: PathHop[] = [];
let prevLat = sender.lat;
let prevLon = sender.lon;
// Start uncertain if sender has no valid location
let prevHopUncertain = !isValidLocation(sender.lat, sender.lon);
for (const prefix of hopPrefixes) {
const matches = findContactsByPrefix(prefix, contacts, true);
const sortedMatches = sortContactsByDistance(matches, prevLat, prevLon);
// Calculate distance from previous hop
// Can't calculate if previous hop was uncertain (unknown/ambiguous/no location) or current hop is unknown/invalid
let distanceFromPrev: number | null = null;
const currentHasValidLocation =
sortedMatches.length === 1 && isValidLocation(sortedMatches[0].lat, sortedMatches[0].lon);
if (!prevHopUncertain && currentHasValidLocation) {
distanceFromPrev = calculateDistance(
prevLat,
prevLon,
sortedMatches[0].lat,
sortedMatches[0].lon
);
}
hops.push({
prefix,
matches: sortedMatches,
distanceFromPrev,
});
// Update previous location for next hop
if (sortedMatches.length === 0) {
// Unknown hop - can't calculate distance for next hop
prevHopUncertain = true;
prevLat = null;
prevLon = null;
} else if (sortedMatches.length > 1) {
// Ambiguous hop - can't calculate distance for next hop (too many combinations)
prevHopUncertain = true;
// Use first match's location for sorting purposes, but distance won't be shown
if (isValidLocation(sortedMatches[0].lat, sortedMatches[0].lon)) {
prevLat = sortedMatches[0].lat;
prevLon = sortedMatches[0].lon;
} else {
prevLat = null;
prevLon = null;
}
} else if (isValidLocation(sortedMatches[0].lat, sortedMatches[0].lon)) {
prevHopUncertain = false;
prevLat = sortedMatches[0].lat;
prevLon = sortedMatches[0].lon;
} else {
// Known hop but no valid location - treat as uncertain for distance purposes
prevHopUncertain = true;
prevLat = null;
prevLon = null;
}
}
// Calculate total distances (can be multiple if ambiguous)
const totalDistances = calculateTotalDistances(resolvedSender, hops, resolvedReceiver);
// Determine if path has any gaps (unknown, ambiguous, or missing location)
const hasGaps =
!isValidLocation(resolvedSender.lat, resolvedSender.lon) ||
!isValidLocation(resolvedReceiver.lat, resolvedReceiver.lon) ||
hops.some(
(hop) => hop.matches.length !== 1 || !isValidLocation(hop.matches[0].lat, hop.matches[0].lon)
);
return {
sender: resolvedSender,
hops,
receiver: resolvedReceiver,
totalDistances,
hasGaps,
};
}
/**
* Calculate total distance(s) for the path
* Returns array for ambiguous paths, null if any segment can't be calculated
* If sender has no location, starts calculating from first hop with location
*/
function calculateTotalDistances(
sender: { lat: number | null; lon: number | null },
hops: PathHop[],
receiver: { lat: number | null; lon: number | null }
): number[] | null {
// Simple case: no hops
if (hops.length === 0) {
if (!isValidLocation(sender.lat, sender.lon) || !isValidLocation(receiver.lat, receiver.lon)) {
return null;
}
const dist = calculateDistance(sender.lat, sender.lon, receiver.lat, receiver.lon);
return dist !== null ? [dist] : null;
}
// Start from sender if it has valid location, otherwise find first hop with valid location
let prevLat = sender.lat;
let prevLon = sender.lon;
let startHopIndex = 0;
if (!isValidLocation(prevLat, prevLon)) {
// Find first hop with a known, unambiguous, valid location
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
if (hop.matches.length === 1 && isValidLocation(hop.matches[0].lat, hop.matches[0].lon)) {
prevLat = hop.matches[0].lat;
prevLon = hop.matches[0].lon;
startHopIndex = i + 1;
break;
}
}
// If no hop has valid location, can't calculate
if (!isValidLocation(prevLat, prevLon)) {
return null;
}
}
// Sum up only unambiguous segments (where both endpoints are known and unambiguous)
let totalDistance = 0;
let hasAnyDistance = false;
let lastUnambiguousHopIndex = -1; // Track last unambiguous hop for receiver distance
for (let i = startHopIndex; i < hops.length; i++) {
const hop = hops[i];
// Skip if hop is unknown or ambiguous or has no valid location
if (hop.matches.length !== 1 || !isValidLocation(hop.matches[0].lat, hop.matches[0].lon)) {
// Can't include this segment - reset prevLat/prevLon for next potential segment
prevLat = null;
prevLon = null;
continue;
}
// Only calculate distance if previous location is known (unambiguous)
if (prevLat !== null && prevLon !== null) {
const dist = calculateDistance(prevLat, prevLon, hop.matches[0].lat, hop.matches[0].lon);
if (dist !== null) {
totalDistance += dist;
hasAnyDistance = true;
}
}
// Update for next iteration
prevLat = hop.matches[0].lat;
prevLon = hop.matches[0].lon;
lastUnambiguousHopIndex = i;
}
// Add final leg to receiver only if last hop was unambiguous and receiver has valid location
if (lastUnambiguousHopIndex === hops.length - 1 && prevLat !== null && prevLon !== null) {
if (isValidLocation(receiver.lat, receiver.lon)) {
const finalDist = calculateDistance(prevLat, prevLon, receiver.lat, receiver.lon);
if (finalDist !== null) {
totalDistance += finalDist;
hasAnyDistance = true;
}
}
}
// Return total if we calculated any distance
return hasAnyDistance ? [totalDistance] : null;
}