Track advert path and use in mesh visualizer

Track advert path and use in mesh visualizer
This commit is contained in:
Jack Kingsman
2026-02-24 14:58:21 -08:00
15 changed files with 580 additions and 33 deletions

View File

@@ -110,6 +110,15 @@ Raw packet handling uses two identities by design:
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
## Repeater Advert Path Memory
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per repeater in a dedicated table (`repeater_advert_paths`).
- This is independent of raw-packet payload deduplication.
- Paths are keyed per repeater + path, with `heard_count`, `first_seen`, and `last_seen`.
- Only the N most recent unique paths are retained per repeater (currently 10).
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
## Data Flow
### Incoming Messages
@@ -259,7 +268,9 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| GET | `/api/contacts` | List contacts |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all repeaters |
| GET | `/api/contacts/{key}` | Get contact by public key or prefix |
| GET | `/api/contacts/{key}/advert-paths` | List recent unique advert paths for one repeater |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| DELETE | `/api/contacts/{key}` | Delete contact |
| POST | `/api/contacts/sync` | Pull from radio |

View File

@@ -59,6 +59,18 @@ CREATE TABLE IF NOT EXISTS raw_packets (
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE TABLE IF NOT EXISTS repeater_advert_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repeater_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(repeater_key, path_hex),
FOREIGN KEY (repeater_key) REFERENCES contacts(public_key)
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
@@ -66,6 +78,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent
ON repeater_advert_paths(repeater_key, last_seen DESC);
"""

View File

@@ -184,6 +184,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 21)
applied += 1
# Migration 22: Track recent unique advert paths per repeater
if version < 22:
logger.info("Applying migration 22: add repeater_advert_paths table")
await _migrate_022_add_repeater_advert_paths(conn)
await set_version(conn, 22)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -1283,3 +1290,31 @@ async def _migrate_021_enforce_min_advert_interval(conn: aiosqlite.Connection) -
)
await conn.commit()
logger.debug("Clamped advert_interval to minimum 3600 seconds")
async def _migrate_022_add_repeater_advert_paths(conn: aiosqlite.Connection) -> None:
"""
Create table for recent unique advert paths per repeater.
This keeps path diversity for repeater advertisements without changing the
existing payload-hash raw packet dedup policy.
"""
await conn.execute("""
CREATE TABLE IF NOT EXISTS repeater_advert_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repeater_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(repeater_key, path_hex),
FOREIGN KEY (repeater_key) REFERENCES contacts(public_key)
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent "
"ON repeater_advert_paths(repeater_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Ensured repeater_advert_paths table and indexes exist")

View File

@@ -72,6 +72,28 @@ class CreateContactRequest(BaseModel):
CONTACT_TYPE_REPEATER = 2
class RepeaterAdvertPath(BaseModel):
"""A unique advert path observed for a repeater."""
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
path_len: int = Field(description="Number of hops in the path")
next_hop: str | None = Field(
default=None, description="First hop toward us (2-char hex), or null for direct"
)
first_seen: int = Field(description="Unix timestamp of first observation")
last_seen: int = Field(description="Unix timestamp of most recent observation")
heard_count: int = Field(description="Number of times this unique path was heard")
class RepeaterAdvertPathSummary(BaseModel):
"""Recent unique advertisement paths for a single repeater."""
repeater_key: str = Field(description="Repeater public key (64-char hex)")
paths: list[RepeaterAdvertPath] = Field(
default_factory=list, description="Most recent unique advert paths"
)
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str

View File

@@ -34,6 +34,7 @@ from app.repository import (
ContactRepository,
MessageRepository,
RawPacketRepository,
RepeaterAdvertPathRepository,
)
from app.websocket import broadcast_error, broadcast_event
@@ -688,6 +689,15 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# Keep recent unique advert paths for repeaters to improve frontend identity hints.
if contact_type == CONTACT_TYPE_REPEATER:
await RepeaterAdvertPathRepository.record_observation(
repeater_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths_per_repeater=10,
)
contact_data = {
"public_key": advert.public_key.lower(),
"name": advert.name,

View File

@@ -15,6 +15,8 @@ from app.models import (
Favorite,
Message,
MessagePath,
RepeaterAdvertPath,
RepeaterAdvertPathSummary,
)
logger = logging.getLogger(__name__)
@@ -241,6 +243,113 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
class RepeaterAdvertPathRepository:
"""Repository for recent unique repeater advertisement paths."""
@staticmethod
def _row_to_path(row) -> RepeaterAdvertPath:
path = row["path_hex"] or ""
next_hop = path[:2].lower() if len(path) >= 2 else None
return RepeaterAdvertPath(
path=path,
path_len=row["path_len"],
next_hop=next_hop,
first_seen=row["first_seen"],
last_seen=row["last_seen"],
heard_count=row["heard_count"],
)
@staticmethod
async def record_observation(
repeater_key: str, path_hex: str, timestamp: int, max_paths_per_repeater: int = 10
) -> None:
"""
Upsert a unique advert path observation for a repeater and prune to N most recent.
"""
if max_paths_per_repeater < 1:
max_paths_per_repeater = 1
normalized_key = repeater_key.lower()
normalized_path = path_hex.lower()
path_len = len(normalized_path) // 2
await db.conn.execute(
"""
INSERT INTO repeater_advert_paths
(repeater_key, path_hex, path_len, first_seen, last_seen, heard_count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(repeater_key, path_hex) DO UPDATE SET
last_seen = MAX(repeater_advert_paths.last_seen, excluded.last_seen),
path_len = excluded.path_len,
heard_count = repeater_advert_paths.heard_count + 1
""",
(normalized_key, normalized_path, path_len, timestamp, timestamp),
)
# Keep only the N most recent unique paths per repeater.
await db.conn.execute(
"""
DELETE FROM repeater_advert_paths
WHERE repeater_key = ?
AND path_hex NOT IN (
SELECT path_hex
FROM repeater_advert_paths
WHERE repeater_key = ?
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
LIMIT ?
)
""",
(normalized_key, normalized_key, max_paths_per_repeater),
)
await db.conn.commit()
@staticmethod
async def get_recent_for_repeater(
repeater_key: str, limit: int = 10
) -> list[RepeaterAdvertPath]:
cursor = await db.conn.execute(
"""
SELECT path_hex, path_len, first_seen, last_seen, heard_count
FROM repeater_advert_paths
WHERE repeater_key = ?
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
LIMIT ?
""",
(repeater_key.lower(), limit),
)
rows = await cursor.fetchall()
return [RepeaterAdvertPathRepository._row_to_path(row) for row in rows]
@staticmethod
async def get_recent_for_all_repeaters(
limit_per_repeater: int = 10,
) -> list[RepeaterAdvertPathSummary]:
cursor = await db.conn.execute(
"""
SELECT repeater_key, path_hex, path_len, first_seen, last_seen, heard_count
FROM repeater_advert_paths
ORDER BY repeater_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
"""
)
rows = await cursor.fetchall()
grouped: dict[str, list[RepeaterAdvertPath]] = {}
for row in rows:
repeater_key = row["repeater_key"]
paths = grouped.get(repeater_key)
if paths is None:
paths = []
grouped[repeater_key] = paths
if len(paths) >= limit_per_repeater:
continue
paths.append(RepeaterAdvertPathRepository._row_to_path(row))
return [
RepeaterAdvertPathSummary(repeater_key=repeater_key, paths=paths)
for repeater_key, paths in grouped.items()
]
class ChannelRepository:
@staticmethod
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:

View File

@@ -16,13 +16,20 @@ from app.models import (
Contact,
CreateContactRequest,
NeighborInfo,
RepeaterAdvertPath,
RepeaterAdvertPathSummary,
TelemetryRequest,
TelemetryResponse,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.radio import radio_manager
from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
MessageRepository,
RepeaterAdvertPathRepository,
)
if TYPE_CHECKING:
from meshcore.events import Event
@@ -182,6 +189,16 @@ async def list_contacts(
return await ContactRepository.get_all(limit=limit, offset=offset)
@router.get("/repeaters/advert-paths", response_model=list[RepeaterAdvertPathSummary])
async def list_repeater_advert_paths(
limit_per_repeater: int = Query(default=10, ge=1, le=50),
) -> list[RepeaterAdvertPathSummary]:
"""List recent unique advert paths for all repeaters."""
return await RepeaterAdvertPathRepository.get_recent_for_all_repeaters(
limit_per_repeater=limit_per_repeater
)
@router.post("", response_model=Contact)
async def create_contact(
request: CreateContactRequest, background_tasks: BackgroundTasks
@@ -265,6 +282,21 @@ async def get_contact(public_key: str) -> Contact:
return await _resolve_contact_or_404(public_key)
@router.get("/{public_key}/advert-paths", response_model=list[RepeaterAdvertPath])
async def get_contact_advert_paths(
public_key: str,
limit: int = Query(default=10, ge=1, le=50),
) -> list[RepeaterAdvertPath]:
"""List recent unique advert paths for a single repeater contact."""
contact = await _resolve_contact_or_404(public_key)
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
)
return await RepeaterAdvertPathRepository.get_recent_for_repeater(contact.public_key, limit)
@router.post("/sync")
async def sync_contacts_from_radio() -> dict:
"""Sync contacts from the radio to the database."""

View File

@@ -12,6 +12,8 @@ import type {
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
RepeaterAdvertPath,
RepeaterAdvertPathSummary,
StatisticsResponse,
TelemetryResponse,
TraceResponse,
@@ -94,6 +96,12 @@ export const api = {
// Contacts
getContacts: (limit = 100, offset = 0) =>
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
fetchJson<RepeaterAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<RepeaterAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',

View File

@@ -174,6 +174,23 @@ function resolveNode(source, isRepeater, showAmbiguous): string | null {
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.
### 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.
**Solution:** The backend tracks recent unique advertisement paths per repeater in `repeater_advert_paths` (see root `AGENTS.md` § "Repeater 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:
- At least 2 matching observations (stronger-than-trivial evidence)
- The top candidate's match score must be at least 2x the runner-up's
When a winner is found, the ambiguous node gets a `probableIdentity` label (the likely repeater's name) and the display name updates accordingly. The remaining candidates are listed as "Other possible" in the tooltip.
**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name.
**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden).
### 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.
@@ -292,6 +309,7 @@ function buildPath(parsed, packet, myPrefix): string[] {
| -------------------------- | ------- | --------------------------------------------------------- |
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
| Let 'em drift | On | Continuous layout optimization |

View File

@@ -16,7 +16,14 @@ import {
} from 'd3-force-3d';
import type { SimulationLinkDatum } from 'd3-force';
import { PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types';
import { api } from '../api';
import {
CONTACT_TYPE_REPEATER,
type Contact,
type RawPacket,
type RadioConfig,
type RepeaterAdvertPathSummary,
} from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { Checkbox } from './ui/checkbox';
import {
@@ -50,6 +57,7 @@ interface GraphNode extends SimulationNodeDatum3D {
isAmbiguous: boolean;
lastActivity: number;
lastSeen?: number | null;
probableIdentity?: string | null;
ambiguousNames?: string[];
}
@@ -110,8 +118,10 @@ interface UseVisualizerData3DOptions {
packets: RawPacket[];
contacts: Contact[];
config: RadioConfig | null;
repeaterAdvertPaths: RepeaterAdvertPathSummary[];
showAmbiguousPaths: boolean;
showAmbiguousNodes: boolean;
useAdvertPathHints: boolean;
splitAmbiguousByTraffic: boolean;
chargeStrength: number;
letEmDrift: boolean;
@@ -133,8 +143,10 @@ function useVisualizerData3D({
packets,
contacts,
config,
repeaterAdvertPaths,
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
splitAmbiguousByTraffic,
chargeStrength,
letEmDrift,
@@ -182,6 +194,15 @@ function useVisualizerData3D({
return { byPrefix12, byName, byPrefix };
}, [contacts]);
const advertPathIndex = useMemo(() => {
const byRepeater = new Map<string, RepeaterAdvertPathSummary['paths']>();
for (const summary of repeaterAdvertPaths) {
const key = summary.repeater_key.slice(0, 12).toLowerCase();
byRepeater.set(key, summary.paths);
}
return { byRepeater };
}, [repeaterAdvertPaths]);
// Keep refs in sync with props
useEffect(() => {
speedMultiplierRef.current = particleSpeedMultiplier;
@@ -304,7 +325,13 @@ function useVisualizerData3D({
trafficPatternsRef.current.clear();
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
syncSimulation();
}, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic, syncSimulation]);
}, [
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
splitAmbiguousByTraffic,
syncSimulation,
]);
const addNode = useCallback(
(
@@ -312,13 +339,15 @@ function useVisualizerData3D({
name: string | null,
type: NodeType,
isAmbiguous: boolean,
probableIdentity?: string | null,
ambiguousNames?: string[],
lastSeen?: number | null
) => {
const existing = nodesRef.current.get(id);
if (existing) {
existing.lastActivity = Date.now();
if (name && !existing.name) existing.name = name;
if (name) existing.name = name;
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
} else {
@@ -332,6 +361,7 @@ function useVisualizerData3D({
type,
isAmbiguous,
lastActivity: Date.now(),
probableIdentity,
lastSeen,
ambiguousNames,
x: r * Math.sin(phi) * Math.cos(theta),
@@ -378,6 +408,48 @@ function useVisualizerData3D({
}
}, []);
const pickLikelyRepeaterByAdvertPath = useCallback(
(candidates: Contact[], nextPrefix: string | null) => {
const nextHop = nextPrefix?.toLowerCase() ?? null;
const scored = candidates
.map((candidate) => {
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
const paths = advertPathIndex.byRepeater.get(prefix12) ?? [];
let matchScore = 0;
let totalScore = 0;
for (const path of paths) {
totalScore += path.heard_count;
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
if (pathNextHop === nextHop) {
matchScore += path.heard_count;
}
}
return { candidate, matchScore, totalScore };
})
.filter((entry) => entry.totalScore > 0)
.sort(
(a, b) =>
b.matchScore - a.matchScore ||
b.totalScore - a.totalScore ||
a.candidate.public_key.localeCompare(b.candidate.public_key)
);
if (scored.length === 0) return null;
const top = scored[0];
const second = scored[1] ?? null;
// Require stronger-than-trivial evidence and a clear winner.
if (top.matchScore < 2) return null;
if (second && top.matchScore < second.matchScore * 2) return null;
return top.candidate;
},
[advertPathIndex]
);
const resolveNode = useCallback(
(
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
@@ -397,6 +469,7 @@ function useVisualizerData3D({
getNodeType(contact),
false,
undefined,
undefined,
contact?.last_seen
);
return nodeId;
@@ -407,11 +480,19 @@ function useVisualizerData3D({
if (contact) {
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
if (myPrefix && nodeId === myPrefix) return 'self';
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
addNode(
nodeId,
contact.name,
getNodeType(contact),
false,
undefined,
undefined,
contact.last_seen
);
return nodeId;
}
const nodeId = `name:${source.value}`;
addNode(nodeId, source.value, 'client', false);
addNode(nodeId, source.value, 'client', false, undefined);
return nodeId;
}
@@ -421,7 +502,15 @@ function useVisualizerData3D({
if (contact) {
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
if (myPrefix && nodeId === myPrefix) return 'self';
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
addNode(
nodeId,
contact.name,
getNodeType(contact),
false,
undefined,
undefined,
contact.last_seen
);
return nodeId;
}
@@ -433,7 +522,7 @@ function useVisualizerData3D({
if (filtered.length === 1) {
const c = filtered[0];
const nodeId = c.public_key.slice(0, 12).toLowerCase();
addNode(nodeId, c.name, getNodeType(c), false, undefined, c.last_seen);
addNode(nodeId, c.name, getNodeType(c), false, undefined, undefined, c.last_seen);
return nodeId;
}
@@ -446,6 +535,20 @@ function useVisualizerData3D({
let nodeId = `?${source.value.toLowerCase()}`;
let displayName = source.value.toUpperCase();
let probableIdentity: string | null = null;
let ambiguousNames = names.length > 0 ? names : undefined;
if (useAdvertPathHints && isRepeater && trafficContext) {
const likely = pickLikelyRepeaterByAdvertPath(filtered, trafficContext.nextPrefix);
if (likely) {
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
probableIdentity = likelyName;
displayName = likelyName;
ambiguousNames = filtered
.filter((c) => c.public_key !== likely.public_key)
.map((c) => c.name || c.public_key.slice(0, 8));
}
}
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
const prefix = source.value.toLowerCase();
@@ -465,7 +568,9 @@ function useVisualizerData3D({
if (analysis.shouldSplit && trafficContext.nextPrefix) {
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
nodeId = `?${prefix}:>${nextShort}`;
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
if (!probableIdentity) {
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
}
}
}
}
@@ -475,7 +580,8 @@ function useVisualizerData3D({
displayName,
isRepeater ? 'repeater' : 'client',
true,
names.length > 0 ? names : undefined,
probableIdentity,
ambiguousNames,
lastSeen
);
return nodeId;
@@ -484,7 +590,13 @@ function useVisualizerData3D({
return null;
},
[contactIndex, addNode, splitAmbiguousByTraffic]
[
contactIndex,
addNode,
useAdvertPathHints,
pickLikelyRepeaterByAdvertPath,
splitAmbiguousByTraffic,
]
);
const buildPath = useCallback(
@@ -892,6 +1004,7 @@ export function PacketVisualizer3D({
// Options
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true);
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false);
const [useAdvertPathHints, setUseAdvertPathHints] = useState(true);
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(true);
const [chargeStrength, setChargeStrength] = useState(-200);
const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC);
@@ -900,6 +1013,31 @@ export function PacketVisualizer3D({
const [showControls, setShowControls] = useState(true);
const [autoOrbit, setAutoOrbit] = useState(false);
const [pruneStaleNodes, setPruneStaleNodes] = useState(false);
const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState<RepeaterAdvertPathSummary[]>([]);
useEffect(() => {
let cancelled = false;
async function loadRepeaterAdvertPaths() {
try {
const data = await api.getRepeaterAdvertPaths(10);
if (!cancelled) {
setRepeaterAdvertPaths(data);
}
} catch (error) {
if (!cancelled) {
// Best-effort hinting; keep visualizer fully functional without this data.
console.debug('Failed to load repeater advert path hints', error);
setRepeaterAdvertPaths([]);
}
}
}
loadRepeaterAdvertPaths();
return () => {
cancelled = true;
};
}, [contacts.length]);
// Hover & click-to-pin
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
@@ -914,8 +1052,10 @@ export function PacketVisualizer3D({
packets,
contacts,
config,
repeaterAdvertPaths,
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
splitAmbiguousByTraffic,
chargeStrength,
letEmDrift,
@@ -1228,9 +1368,7 @@ export function PacketVisualizer3D({
if (nd.labelDiv.style.color !== labelColor) {
nd.labelDiv.style.color = labelColor;
}
const labelText = node.isAmbiguous
? node.id
: node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
if (nd.labelDiv.textContent !== labelText) {
nd.labelDiv.textContent = labelText;
}
@@ -1535,6 +1673,19 @@ export function PacketVisualizer3D({
Show ambiguous sender/recipient
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={useAdvertPathHints}
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
disabled={!showAmbiguousPaths}
/>
<span
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes"
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
>
Use repeater advert-path identity hints
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={splitAmbiguousByTraffic}
@@ -1702,9 +1853,13 @@ export function PacketVisualizer3D({
Type: {node.type}
{node.isAmbiguous ? ' (ambiguous)' : ''}
</div>
{node.probableIdentity && (
<div className="text-muted-foreground">Probably: {node.probableIdentity}</div>
)}
{node.ambiguousNames && node.ambiguousNames.length > 0 && (
<div className="text-muted-foreground">
Possible: {node.ambiguousNames.join(', ')}
{node.probableIdentity ? 'Other possible: ' : 'Possible: '}
{node.ambiguousNames.join(', ')}
</div>
)}
{neighbors.length > 0 && (

View File

@@ -106,6 +106,19 @@ describe('fetchJson (via api methods)', () => {
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts?limit=100&offset=0');
});
it('builds repeater advert path endpoint query', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getRepeaterAdvertPaths(12);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
});
});
describe('error handling', () => {

View File

@@ -52,6 +52,20 @@ export interface Contact {
last_read_at: number | null;
}
export interface RepeaterAdvertPath {
path: string;
path_len: number;
next_hop: string | null;
first_seen: number;
last_seen: number;
heard_count: number;
}
export interface RepeaterAdvertPathSummary {
repeater_key: string;
paths: RepeaterAdvertPath[];
}
export interface Channel {
key: string;
name: string;

View File

@@ -15,7 +15,7 @@ from meshcore import EventType
from app.database import Database
from app.radio import radio_manager
from app.repository import ContactRepository, MessageRepository
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
# Sample 64-char hex public keys for testing
KEY_A = "aa" * 32 # aaaa...aa
@@ -208,6 +208,50 @@ class TestGetContact:
assert "ambiguous" in response.json()["detail"].lower()
class TestAdvertPaths:
"""Test repeater advert path endpoints."""
@pytest.mark.asyncio
async def test_list_repeater_advert_paths(self, test_db, client):
repeater_key = KEY_A
await _insert_contact(repeater_key, "R1", type=2)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "1122", 1000)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "3344", 1010)
response = await client.get("/api/contacts/repeaters/advert-paths?limit_per_repeater=1")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["repeater_key"] == repeater_key
assert len(data[0]["paths"]) == 1
assert data[0]["paths"][0]["path"] == "3344"
assert data[0]["paths"][0]["next_hop"] == "33"
@pytest.mark.asyncio
async def test_get_contact_advert_paths_for_repeater(self, test_db, client):
repeater_key = KEY_A
await _insert_contact(repeater_key, "R1", type=2)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "", 1000)
response = await client.get(f"/api/contacts/{repeater_key}/advert-paths")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["path"] == ""
assert data[0]["next_hop"] is None
@pytest.mark.asyncio
async def test_get_contact_advert_paths_rejects_non_repeater(self, test_db, client):
await _insert_contact(KEY_A, "Alice", type=1)
response = await client.get(f"/api/contacts/{KEY_A}/advert-paths")
assert response.status_code == 400
assert "not a repeater" in response.json()["detail"].lower()
class TestMarkRead:
"""Test POST /api/contacts/{public_key}/mark-read."""

View File

@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 21 # All migrations run
assert await get_version(conn) == 21
assert applied == 22 # All migrations run
assert await get_version(conn) == 22
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 21 # All 21 migrations run
assert applied1 == 22 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 21
assert await get_version(conn) == 22
finally:
await conn.close()
@@ -246,8 +246,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
assert applied == 21
assert await get_version(conn) == 21
assert applied == 22
assert await get_version(conn) == 22
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-21 which also run)
# Run migration 13 (plus 14-22 which also run)
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 21
assert applied == 10
assert await get_version(conn) == 22
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
@@ -497,7 +497,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 21
assert await get_version(conn) == 22
# Verify autoindex is gone
cursor = await conn.execute(
@@ -571,8 +571,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 4 # Migrations 18+19+20+21 run (18+19 skip internally)
assert await get_version(conn) == 21
assert applied == 5 # Migrations 18+19+20+21+22 run (18+19 skip internally)
assert await get_version(conn) == 22
finally:
await conn.close()
@@ -644,7 +644,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 21
assert await get_version(conn) == 22
# Verify autoindex is gone
cursor = await conn.execute(
@@ -710,8 +710,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 2 # Migrations 20+21
assert await get_version(conn) == 21
assert applied == 3 # Migrations 20+21+22
assert await get_version(conn) == 22
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -741,7 +741,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 1 # Migration 21 still runs
assert applied == 2 # Migrations 21+22 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")

View File

@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.database import Database
from app.repository import MessageRepository
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
@pytest.fixture
@@ -298,6 +298,68 @@ class TestMessageRepositoryGetAckCount:
assert result == 0
class TestRepeaterAdvertPathRepository:
"""Test storing and retrieving recent unique repeater advert paths."""
@pytest.mark.asyncio
async def test_record_observation_upserts_and_tracks_count(self, test_db):
repeater_key = "aa" * 32
await ContactRepository.upsert({"public_key": repeater_key, "name": "R1", "type": 2})
await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1000)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1010)
paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10)
assert len(paths) == 1
assert paths[0].path == "112233"
assert paths[0].path_len == 3
assert paths[0].next_hop == "11"
assert paths[0].first_seen == 1000
assert paths[0].last_seen == 1010
assert paths[0].heard_count == 2
@pytest.mark.asyncio
async def test_prunes_to_most_recent_n_unique_paths(self, test_db):
repeater_key = "bb" * 32
await ContactRepository.upsert({"public_key": repeater_key, "name": "R2", "type": 2})
await RepeaterAdvertPathRepository.record_observation(
repeater_key, "aa", 1000, max_paths_per_repeater=2
)
await RepeaterAdvertPathRepository.record_observation(
repeater_key, "bb", 1001, max_paths_per_repeater=2
)
await RepeaterAdvertPathRepository.record_observation(
repeater_key, "cc", 1002, max_paths_per_repeater=2
)
paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10)
assert [p.path for p in paths] == ["cc", "bb"]
@pytest.mark.asyncio
async def test_get_recent_for_all_repeaters_respects_limit(self, test_db):
repeater_a = "cc" * 32
repeater_b = "dd" * 32
await ContactRepository.upsert({"public_key": repeater_a, "name": "RA", "type": 2})
await ContactRepository.upsert({"public_key": repeater_b, "name": "RB", "type": 2})
await RepeaterAdvertPathRepository.record_observation(repeater_a, "01", 1000)
await RepeaterAdvertPathRepository.record_observation(repeater_a, "02", 1001)
await RepeaterAdvertPathRepository.record_observation(repeater_b, "", 1002)
grouped = await RepeaterAdvertPathRepository.get_recent_for_all_repeaters(
limit_per_repeater=1
)
by_key = {item.repeater_key: item.paths for item in grouped}
assert repeater_a in by_key
assert repeater_b in by_key
assert len(by_key[repeater_a]) == 1
assert by_key[repeater_a][0].path == "02"
assert by_key[repeater_b][0].path == ""
assert by_key[repeater_b][0].next_hop is None
class TestAppSettingsRepository:
"""Test AppSettingsRepository parsing and migration edge cases."""