mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Fix visualizer coercion for multibyte hops
This commit is contained in:
@@ -172,15 +172,15 @@ function resolveNode(source, isRepeater, showAmbiguous): string | null {
|
||||
|
||||
### Ambiguous Nodes
|
||||
|
||||
When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity.
|
||||
When only a partial hop token is known (for example a 1-byte hop from an older radio), the node is marked ambiguous and shown with a `?` prefix and gray styling. Full 2-byte and 3-byte hop tokens are preserved as distinct identities and are not collapsed back to their first byte. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity.
|
||||
|
||||
### Advert-Path Identity Hints
|
||||
|
||||
**Problem:** When multiple repeaters share a 1-byte prefix, the visualizer can't tell which physical repeater a path hop refers to.
|
||||
**Problem:** During mixed-radio operation, some observations may only carry a 1-byte hop while others carry a full 2-byte or 3-byte hop token. The visualizer must not collapse the full token back to the first byte, but it also cannot over-resolve the short token.
|
||||
|
||||
**Solution:** The backend tracks recent unique advertisement paths per contact in `contact_advert_paths` (see root `AGENTS.md` § "Contact Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix.
|
||||
|
||||
**Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop prefix. It requires:
|
||||
**Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop token. It requires:
|
||||
|
||||
- At least 2 matching observations (stronger-than-trivial evidence)
|
||||
- The top candidate's match score must be at least 2x the runner-up's
|
||||
@@ -193,7 +193,7 @@ When a winner is found, the ambiguous node gets a `probableIdentity` label (the
|
||||
|
||||
### Traffic Pattern Splitting (Experimental)
|
||||
|
||||
**Problem:** Multiple physical repeaters can share the same 1-byte prefix (collision). Since packet paths only contain 1-byte hashes, we can't directly distinguish them. However, traffic patterns provide a heuristic.
|
||||
**Problem:** Multiple physical repeaters can share the same short hop token emitted by older radios. Since those packets only carry the short token, we can't directly distinguish them. However, traffic patterns provide a heuristic.
|
||||
|
||||
**Key Insight:** A single physical repeater (even acting as a hub) will have the same sources routing through it regardless of next-hop. But if prefix `32` has completely disjoint sets of sources for different next-hops, those are likely different physical nodes sharing the same prefix.
|
||||
|
||||
@@ -234,9 +234,9 @@ Here source `ae` routes through `32` to BOTH `ba` and `60`. This proves `32` is
|
||||
|
||||
**Node ID format:**
|
||||
|
||||
- Without splitting (default): `?XX` (e.g., `?32`)
|
||||
- With splitting (after evidence threshold met): `?XX:>YY` (e.g., `?32:>ba`)
|
||||
- Final repeater: `?XX` (unchanged, no suffix)
|
||||
- Without splitting (default): `?{hop}` (examples: `?32`, `?aa11`, `?bb22cc`)
|
||||
- With splitting (after evidence threshold met): `?{hop}:>{nextHop}` (example: `?32:>ba`, `?aa11:>bb22`)
|
||||
- Final repeater: `?{hop}` (unchanged, no suffix)
|
||||
|
||||
## Path Building
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
type Particle,
|
||||
type PendingPacket,
|
||||
type RepeaterTrafficData,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
COLORS,
|
||||
PARTICLE_COLOR_MAP,
|
||||
PARTICLE_SPEED,
|
||||
@@ -538,7 +540,8 @@ function useVisualizerData3D({
|
||||
}
|
||||
|
||||
// type === 'prefix'
|
||||
const matches = contactIndex.byPrefix.get(source.value.toLowerCase()) ?? [];
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
@@ -584,13 +587,14 @@ function useVisualizerData3D({
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = `?${source.value.toLowerCase()}`;
|
||||
let displayName = source.value.toUpperCase();
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const likely = pickLikelyRepeaterByAdvertPath(filtered, trafficContext.nextPrefix);
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
@@ -602,25 +606,24 @@ function useVisualizerData3D({
|
||||
}
|
||||
|
||||
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const prefix = source.value.toLowerCase();
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
trafficPatternsRef.current,
|
||||
prefix,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
trafficContext.nextPrefix
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = trafficPatternsRef.current.get(prefix);
|
||||
const trafficData = trafficPatternsRef.current.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && trafficContext.nextPrefix) {
|
||||
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
|
||||
nodeId = `?${prefix}:>${nextShort}`;
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
recordTrafficObservation,
|
||||
type RepeaterTrafficData,
|
||||
} from '../utils/visualizerUtils';
|
||||
|
||||
describe('visualizer multibyte hop identity helpers', () => {
|
||||
it('preserves the full hop token in ambiguous node ids', () => {
|
||||
expect(buildAmbiguousRepeaterNodeId('aa11')).toBe('?aa11');
|
||||
expect(buildAmbiguousRepeaterNodeId('bb22cc')).toBe('?bb22cc');
|
||||
});
|
||||
|
||||
it('preserves the full current and next hop tokens in traffic split ids', () => {
|
||||
expect(buildAmbiguousRepeaterNodeId('aa', 'bb22')).toBe('?aa:>bb22');
|
||||
expect(buildAmbiguousRepeaterNodeId('aa11', 'cc33dd')).toBe('?aa11:>cc33dd');
|
||||
});
|
||||
|
||||
it('formats labels from full hop tokens', () => {
|
||||
expect(buildAmbiguousRepeaterLabel('aa11')).toBe('AA11');
|
||||
expect(buildAmbiguousRepeaterLabel('aa11', 'bb22')).toBe('AA11:>BB22');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visualizer traffic pattern grouping', () => {
|
||||
it('tracks traffic using full hop tokens instead of first-byte buckets', () => {
|
||||
const traffic = new Map<string, RepeaterTrafficData>();
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
recordTrafficObservation(traffic, 'aa11', `src-a-${i}`, 'bb22');
|
||||
recordTrafficObservation(traffic, 'aa22', `src-b-${i}`, 'bb33');
|
||||
}
|
||||
|
||||
expect(traffic.has('aa11')).toBe(true);
|
||||
expect(traffic.has('aa22')).toBe(true);
|
||||
expect(traffic.has('aa')).toBe(false);
|
||||
|
||||
const firstTraffic = traffic.get('aa11');
|
||||
const secondTraffic = traffic.get('aa22');
|
||||
expect(firstTraffic).toBeDefined();
|
||||
expect(secondTraffic).toBeDefined();
|
||||
|
||||
const first = analyzeRepeaterTraffic(firstTraffic!);
|
||||
const second = analyzeRepeaterTraffic(secondTraffic!);
|
||||
expect(first.shouldSplit).toBe(false);
|
||||
expect(second.shouldSplit).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ interface TrafficObservation {
|
||||
}
|
||||
|
||||
export interface RepeaterTrafficData {
|
||||
prefix: string; // The 1-byte hex prefix (e.g., "32")
|
||||
hopKey: string; // The observed hop token (e.g. "32", "aa11", or "bbccdd")
|
||||
observations: TrafficObservation[];
|
||||
}
|
||||
|
||||
@@ -110,6 +110,31 @@ export const PACKET_LEGEND_ITEMS = [
|
||||
{ label: '?', color: COLORS.particleUnknown, description: 'Other' },
|
||||
] as const;
|
||||
|
||||
export function normalizeHopToken(hop: string | null | undefined): string | null {
|
||||
const normalized = hop?.trim().toLowerCase() ?? '';
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function buildAmbiguousRepeaterNodeId(hop: string, nextHop?: string | null): string {
|
||||
const hopKey = normalizeHopToken(hop);
|
||||
if (!hopKey) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
const nextHopKey = normalizeHopToken(nextHop);
|
||||
return nextHopKey ? `?${hopKey}:>${nextHopKey}` : `?${hopKey}`;
|
||||
}
|
||||
|
||||
export function buildAmbiguousRepeaterLabel(hop: string, nextHop?: string | null): string {
|
||||
const hopKey = normalizeHopToken(hop)?.toUpperCase();
|
||||
if (!hopKey) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
const nextHopKey = normalizeHopToken(nextHop)?.toUpperCase();
|
||||
return nextHopKey ? `${hopKey}:>${nextHopKey}` : hopKey;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// UTILITY FUNCTIONS (Data Layer)
|
||||
// =============================================================================
|
||||
@@ -274,21 +299,26 @@ export function analyzeRepeaterTraffic(data: RepeaterTrafficData): RepeaterSplit
|
||||
*/
|
||||
export function recordTrafficObservation(
|
||||
trafficData: Map<string, RepeaterTrafficData>,
|
||||
prefix: string,
|
||||
hopKey: string,
|
||||
source: string,
|
||||
nextHop: string | null
|
||||
): void {
|
||||
const normalizedPrefix = prefix.toLowerCase();
|
||||
const now = Date.now();
|
||||
|
||||
if (!trafficData.has(normalizedPrefix)) {
|
||||
trafficData.set(normalizedPrefix, { prefix: normalizedPrefix, observations: [] });
|
||||
const normalizedHopKey = normalizeHopToken(hopKey);
|
||||
if (!normalizedHopKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = trafficData.get(normalizedPrefix)!;
|
||||
const normalizedNextHop = normalizeHopToken(nextHop);
|
||||
const now = Date.now();
|
||||
|
||||
if (!trafficData.has(normalizedHopKey)) {
|
||||
trafficData.set(normalizedHopKey, { hopKey: normalizedHopKey, observations: [] });
|
||||
}
|
||||
|
||||
const data = trafficData.get(normalizedHopKey)!;
|
||||
|
||||
// Add new observation
|
||||
data.observations.push({ source, nextHop, timestamp: now });
|
||||
data.observations.push({ source, nextHop: normalizedNextHop, timestamp: now });
|
||||
|
||||
// Prune old observations
|
||||
data.observations = data.observations.filter(
|
||||
|
||||
Reference in New Issue
Block a user