diff --git a/app/models.py b/app/models.py index 010c20a..8857c4d 100644 --- a/app/models.py +++ b/app/models.py @@ -628,6 +628,59 @@ class TraceResponse(BaseModel): path_len: int = Field(description="Number of hops in the trace path") +class RadioTraceHopRequest(BaseModel): + """One requested hop in a radio trace path.""" + + public_key: str | None = Field( + default=None, + description="Full repeater public key when this hop maps to a known repeater", + ) + hop_hex: str | None = Field( + default=None, + description="Raw hop hash hex when using a custom repeater prefix", + ) + + +class RadioTraceRequest(BaseModel): + """Ordered trace path for a radio trace loop.""" + + hop_hash_bytes: Literal[1, 2, 4] = Field( + default=4, + description="Hash width in bytes for every hop in this trace path", + ) + hops: list[RadioTraceHopRequest] = Field( + min_length=1, + description="Ordered repeater hops, using either known repeater keys or custom hop hex", + ) + + +class RadioTraceNode(BaseModel): + """One resolved node in a radio trace result.""" + + role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace") + public_key: str | None = Field( + default=None, + description="Resolved full public key for this node when known", + ) + name: str | None = Field(default=None, description="Display name for this node when known") + observed_hash: str | None = Field( + default=None, + description="Observed 4-byte trace hash for this node as hex", + ) + snr: float | None = Field(default=None, description="Reported SNR for this node in dB") + + +class RadioTraceResponse(BaseModel): + """Resolved multi-hop radio trace result.""" + + path_len: int = Field(description="Number of hashed nodes returned by the trace response") + timeout_seconds: float = Field(description="Timeout window used while waiting for the trace") + nodes: list[RadioTraceNode] = Field( + default_factory=list, + description="Ordered trace nodes: repeater hops followed by the terminal local radio", + ) + + class PathDiscoveryRoute(BaseModel): """One resolved route returned by contact path discovery.""" diff --git a/app/routers/radio.py b/app/routers/radio.py index 1d8d3f8..920697a 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -2,6 +2,7 @@ import asyncio import logging import random import time +from contextlib import suppress from typing import Literal, TypeAlias from fastapi import APIRouter, HTTPException @@ -10,10 +11,15 @@ from pydantic import BaseModel, Field from app.dependencies import require_connected from app.models import ( + CONTACT_TYPE_REPEATER, ContactUpsert, RadioDiscoveryRequest, RadioDiscoveryResponse, RadioDiscoveryResult, + RadioTraceHopRequest, + RadioTraceNode, + RadioTraceRequest, + RadioTraceResponse, ) from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time @@ -45,6 +51,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = { 2: "repeater", 4: "sensor", } +TRACE_WAIT_TIMEOUT_SECONDS = 45.0 +TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0 +TRACE_TIMEOUT_MIN_SECONDS = 5.0 +TRACE_TIMEOUT_MAX_SECONDS = 30.0 +TRACE_TIMEOUT_MARGIN = 1.2 +TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2} async def _prepare_connected(*, broadcast_on_success: bool) -> bool: @@ -217,6 +229,101 @@ async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None: result.name = contact.name +def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str: + return public_key[: hop_hash_bytes * 2].lower() + + +def _trace_timeout_seconds(send_result: object) -> float: + payload = getattr(send_result, "payload", None) or {} + suggested_timeout = payload.get("suggested_timeout") + try: + if suggested_timeout is None: + raise TypeError + timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN + except (TypeError, ValueError): + timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS + return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds)) + + +async def _resolve_trace_hops( + hops: list[RadioTraceHopRequest], hop_hash_bytes: int +) -> tuple[list[RadioTraceNode], list[str]]: + trace_nodes: list[RadioTraceNode] = [] + requested_hashes: list[str] = [] + expected_hex_len = hop_hash_bytes * 2 + + for hop in hops: + public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None + hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None + if bool(public_key) == bool(hop_hex): + raise HTTPException( + status_code=400, + detail="Each trace hop must provide exactly one of public_key or hop_hex", + ) + + if public_key: + if len(public_key) != 64: + raise HTTPException( + status_code=400, + detail="Trace repeater keys must be full 64-character public keys", + ) + try: + bytes.fromhex(public_key) + except ValueError as exc: + raise HTTPException( + status_code=400, + detail="Trace repeater keys must be valid hex public keys", + ) from exc + + contact = await ContactRepository.get_by_key(public_key) + if contact is None: + raise HTTPException( + status_code=404, detail=f"Trace repeater not found: {public_key}" + ) + if contact.type != CONTACT_TYPE_REPEATER: + raise HTTPException( + status_code=400, + detail=f"Trace node is not a repeater: {public_key[:12]}", + ) + requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes)) + trace_nodes.append( + RadioTraceNode( + role="repeater", + public_key=contact.public_key, + name=contact.name, + observed_hash=None, + snr=None, + ) + ) + continue + + assert hop_hex is not None + if len(hop_hex) != expected_hex_len: + raise HTTPException( + status_code=400, + detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters", + ) + try: + bytes.fromhex(hop_hex) + except ValueError as exc: + raise HTTPException( + status_code=400, + detail="Custom trace hops must be valid hex", + ) from exc + requested_hashes.append(hop_hex) + trace_nodes.append( + RadioTraceNode( + role="custom", + public_key=None, + name=None, + observed_hash=hop_hex, + snr=None, + ) + ) + + return trace_nodes, requested_hashes + + @router.get("/config", response_model=RadioConfigResponse) async def get_radio_config() -> RadioConfigResponse: """Get the current radio configuration.""" @@ -388,6 +495,105 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons ) +@router.post("/trace", response_model=RadioTraceResponse) +async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: + """Send a multi-hop trace loop through known repeaters and back to the local radio.""" + require_connected() + trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes) + + tag = random.randint(1, 0xFFFFFFFF) + trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes] + + async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc: + local_public_key = str((mc.self_info or {}).get("public_key") or "").lower() + if len(local_public_key) != 64: + raise HTTPException(status_code=503, detail="Local radio public key is unavailable") + local_name = (mc.self_info or {}).get("name") + + response_task = asyncio.create_task( + mc.wait_for_event( + EventType.TRACE_DATA, + attribute_filters={"tag": tag}, + timeout=TRACE_WAIT_TIMEOUT_SECONDS, + ) + ) + try: + send_result = await mc.commands.send_trace( + path=",".join(requested_hashes), + tag=tag, + flags=trace_flags, + ) + if send_result is None or send_result.type == EventType.ERROR: + raise HTTPException(status_code=500, detail="Failed to send trace") + + timeout_seconds = _trace_timeout_seconds(send_result) + try: + event = await asyncio.wait_for(response_task, timeout=timeout_seconds) + except asyncio.TimeoutError as exc: + raise HTTPException(status_code=504, detail="No trace response heard") from exc + finally: + if not response_task.done(): + response_task.cancel() + with suppress(asyncio.CancelledError): + await response_task + + if event is None: + raise HTTPException(status_code=504, detail="No trace response heard") + + payload = event.payload if isinstance(event.payload, dict) else {} + path_len = payload.get("path_len") + if not isinstance(path_len, int): + raise HTTPException(status_code=500, detail="Trace response was malformed") + + raw_path = payload.get("path") + path_nodes = raw_path if isinstance(raw_path, list) else [] + final_local_node = ( + path_nodes[-1] + if path_nodes + and isinstance(path_nodes[-1], dict) + and not isinstance(path_nodes[-1].get("hash"), str) + else None + ) + hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes + + if len(hashed_nodes) < len(trace_nodes): + raise HTTPException(status_code=500, detail="Trace response was incomplete") + + nodes: list[RadioTraceNode] = [] + for index, trace_node in enumerate(trace_nodes): + observed = hashed_nodes[index] if index < len(hashed_nodes) else {} + observed_hash = observed.get("hash") if isinstance(observed, dict) else None + observed_snr = observed.get("snr") if isinstance(observed, dict) else None + nodes.append( + RadioTraceNode( + role=trace_node.role, + public_key=trace_node.public_key, + name=trace_node.name, + observed_hash=( + observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash + ), + snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None, + ) + ) + + terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None + nodes.append( + RadioTraceNode( + role="local", + public_key=local_public_key, + name=local_name if isinstance(local_name, str) and local_name else None, + observed_hash=None, + snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None, + ) + ) + + return RadioTraceResponse( + path_len=path_len, + timeout_seconds=timeout_seconds, + nodes=nodes, + ) + + async def _attempt_reconnect() -> dict: """Shared reconnection logic for reboot and reconnect endpoints.""" radio_manager.resume_connection() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 601f458..b11009a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -457,6 +457,7 @@ export function App() { loadingNewer, messageInputRef, onTrace: handleTrace, + onRunTracePath: api.requestRadioTrace, onPathDiscovery: handlePathDiscovery, onToggleFavorite: handleToggleFavorite, onDeleteContact: handleDeleteContact, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ab7644b..ace9789 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -20,6 +20,8 @@ import type { RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, + RadioTraceHopRequest, + RadioTraceResponse, RadioDiscoveryTarget, PathDiscoveryResponse, ResendChannelMessageResponse, @@ -107,6 +109,11 @@ export const api = { method: 'POST', body: JSON.stringify({ target }), }), + requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) => + fetchJson('/radio/trace', { + method: 'POST', + body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }), + }), rebootRadio: () => fetchJson<{ status: string; message: string }>('/radio/reboot', { method: 'POST', diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 809fda3..aa97aeb 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput'; import { MessageList } from './MessageList'; import { RawPacketFeedView } from './RawPacketFeedView'; import { RoomServerPanel } from './RoomServerPanel'; +import { TracePane } from './TracePane'; import type { Channel, Contact, @@ -15,6 +16,8 @@ import type { PathDiscoveryResponse, RawPacket, RadioConfig, + RadioTraceHopRequest, + RadioTraceResponse, } from '../types'; import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; @@ -50,6 +53,10 @@ interface ConversationPaneProps { loadingNewer: boolean; messageInputRef: Ref; onTrace: () => Promise; + onRunTracePath: ( + hopHashBytes: 1 | 2 | 4, + hops: RadioTraceHopRequest[] + ) => Promise; onPathDiscovery: (publicKey: string) => Promise; onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; onDeleteContact: (publicKey: string) => Promise; @@ -115,6 +122,7 @@ export function ConversationPane({ loadingNewer, messageInputRef, onTrace, + onRunTracePath, onPathDiscovery, onToggleFavorite, onDeleteContact, @@ -200,6 +208,10 @@ export function ConversationPane({ return null; } + if (activeConversation.type === 'trace') { + return ; + } + if (activeContactIsRepeater) { return ( }> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8a716de..088031b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bell, + Cable, + ChartNetwork, CheckCheck, ChevronDown, ChevronRight, @@ -9,7 +11,6 @@ import { Map, Search as SearchIcon, SquarePen, - Waypoints, X, } from 'lucide-react'; import { @@ -197,7 +198,7 @@ export function Sidebar({ }; const isActive = ( - type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search', + type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace', id: string ) => activeConversation?.type === type && activeConversation?.id === id; @@ -721,7 +722,7 @@ export function Sidebar({ renderSidebarActionRow({ key: 'tool-visualizer', active: isActive('visualizer', 'visualizer'), - icon: , + icon: , label: 'Mesh Visualizer', onClick: () => handleSelectConversation({ @@ -730,6 +731,18 @@ export function Sidebar({ name: 'Mesh Visualizer', }), }), + renderSidebarActionRow({ + key: 'tool-trace', + active: isActive('trace', 'trace'), + icon: , + label: 'Trace', + onClick: () => + handleSelectConversation({ + type: 'trace', + id: 'trace', + name: 'Trace', + }), + }), renderSidebarActionRow({ key: 'tool-search', active: isActive('search', 'search'), diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx new file mode 100644 index 0000000..54c2da1 --- /dev/null +++ b/frontend/src/components/TracePane.tsx @@ -0,0 +1,691 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react'; + +import type { + Contact, + RadioConfig, + RadioTraceHopRequest, + RadioTraceNode, + RadioTraceResponse, +} from '../types'; +import { CONTACT_TYPE_REPEATER } from '../types'; +import { calculateDistance, isValidLocation } from '../utils/pathUtils'; +import { getContactDisplayName } from '../utils/pubkey'; +import { handleKeyboardActivate } from '../utils/a11y'; +import { ContactAvatar } from './ContactAvatar'; +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Input } from './ui/input'; +import { cn } from '@/lib/utils'; + +type TraceSortMode = 'alpha' | 'recent' | 'distance'; +type CustomHopBytes = 1 | 2 | 4; + +type TraceDraftHop = + | { id: string; kind: 'repeater'; publicKey: string } + | { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes }; + +interface TracePaneProps { + contacts: Contact[]; + config: RadioConfig | null; + onRunTracePath: ( + hopHashBytes: CustomHopBytes, + hops: RadioTraceHopRequest[] + ) => Promise; +} + +function getHeardTimestamp(contact: Contact): number { + return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0); +} + +function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null { + if ( + !config || + !isValidLocation(config.lat, config.lon) || + !isValidLocation(contact.lat, contact.lon) + ) { + return null; + } + return calculateDistance(config.lat, config.lon, contact.lat, contact.lon); +} + +function getShortKey(publicKey: string | null | undefined): string { + if (!publicKey) return 'unknown'; + return publicKey.slice(0, 12); +} + +function formatSNR(snr: number | null | undefined): string { + if (typeof snr !== 'number' || Number.isNaN(snr)) { + return '—'; + } + return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`; +} + +function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] { + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= hops.length) { + return hops; + } + const next = [...hops]; + const [item] = next.splice(index, 1); + next.splice(nextIndex, 0, item); + return next; +} + +function normalizeCustomHopHex(value: string): string { + return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase(); +} + +function nextDraftHopId(prefix: string, currentLength: number): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${currentLength}`; +} + +function TraceNodeRow({ + title, + subtitle, + meta, + note, + fixed = false, + compact = false, + actions, + snr, +}: { + title: string; + subtitle: string; + meta?: string | null; + note?: string | null; + fixed?: boolean; + compact?: boolean; + actions?: ReactNode; + snr?: string | null; +}) { + return ( +
+
+ {fixed ? 'Self' : 'Hop'} +
+
+
{title}
+
{subtitle}
+ {meta ?
{meta}
: null} + {note ?
{note}
: null} +
+ {snr ? ( +
+
SNR
+
{snr}
+
+ ) : null} + {actions ?
{actions}
: null} +
+ ); +} + +export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortMode, setSortMode] = useState('alpha'); + const [draftHops, setDraftHops] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [customDialogOpen, setCustomDialogOpen] = useState(false); + const [customHopBytesDraft, setCustomHopBytesDraft] = useState(1); + const [customHopHexDraft, setCustomHopHexDraft] = useState(''); + const [customHopError, setCustomHopError] = useState(null); + const activeRunTokenRef = useRef(0); + + const repeaters = useMemo(() => { + const deduped = new Map(); + for (const contact of contacts) { + if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) { + continue; + } + if (!deduped.has(contact.public_key)) { + deduped.set(contact.public_key, contact); + } + } + return [...deduped.values()]; + }, [contacts]); + + const repeatersByKey = useMemo( + () => new Map(repeaters.map((contact) => [contact.public_key, contact])), + [repeaters] + ); + + const filteredRepeaters = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const matching = query + ? repeaters.filter( + (contact) => + contact.public_key.toLowerCase().includes(query) || + (contact.name ?? '').toLowerCase().includes(query) + ) + : repeaters; + + return [...matching].sort((left, right) => { + if (sortMode === 'recent') { + const leftTs = getHeardTimestamp(left); + const rightTs = getHeardTimestamp(right); + if (leftTs !== rightTs) { + return rightTs - leftTs; + } + } + if (sortMode === 'distance') { + const leftDistance = getDistanceKm(left, config); + const rightDistance = getDistanceKm(right, config); + if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) { + return leftDistance - rightDistance; + } + if (leftDistance !== null && rightDistance === null) return -1; + if (leftDistance === null && rightDistance !== null) return 1; + } + return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare( + getContactDisplayName(right.name, right.public_key, right.last_advert) + ); + }); + }, [config, repeaters, searchQuery, sortMode]); + + const localRadioName = config?.name || 'Local radio'; + const localRadioKey = config?.public_key ?? null; + const canSortByDistance = !!config && isValidLocation(config.lat, config.lon); + const customHopBytesLocked = useMemo( + () => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null, + [draftHops] + ); + const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4; + + useEffect(() => { + if (!customDialogOpen) return; + setCustomHopBytesDraft(customHopBytesLocked ?? 1); + setCustomHopHexDraft(''); + setCustomHopError(null); + }, [customDialogOpen, customHopBytesLocked]); + + const clearPendingResult = () => { + activeRunTokenRef.current += 1; + setLoading(false); + if (result) setResult(null); + if (error) setError(null); + }; + + const handleAddRepeater = (publicKey: string) => { + setDraftHops((current) => [ + ...current, + { + id: nextDraftHopId('repeater', current.length), + kind: 'repeater', + publicKey, + }, + ]); + clearPendingResult(); + }; + + const handleAddCustomHop = () => { + const hopBytes = customHopBytesLocked ?? customHopBytesDraft; + const hopHex = normalizeCustomHopHex(customHopHexDraft); + if (hopHex.length !== hopBytes * 2) { + setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`); + return; + } + setDraftHops((current) => [ + ...current, + { + id: nextDraftHopId('custom', current.length), + kind: 'custom', + hopHex, + hopBytes, + }, + ]); + clearPendingResult(); + setCustomDialogOpen(false); + }; + + const handleRemoveHop = (id: string) => { + setDraftHops((current) => current.filter((hop) => hop.id !== id)); + clearPendingResult(); + }; + + const handleMoveHop = (index: number, direction: -1 | 1) => { + setDraftHops((current) => moveHop(current, index, direction)); + clearPendingResult(); + }; + + const handleRunTrace = async () => { + if (draftHops.length === 0) { + return; + } + const runToken = activeRunTokenRef.current + 1; + activeRunTokenRef.current = runToken; + setLoading(true); + setError(null); + setResult(null); + try { + const traceResult = await onRunTracePath( + effectiveHopHashBytes, + draftHops.map((hop) => + hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex } + ) + ); + if (activeRunTokenRef.current !== runToken) { + return; + } + setResult(traceResult); + } catch (err) { + if (activeRunTokenRef.current !== runToken) { + return; + } + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (activeRunTokenRef.current === runToken) { + setLoading(false); + } + } + }; + + const resultNodes: RadioTraceNode[] = result + ? [ + { + role: 'local', + public_key: localRadioKey, + name: localRadioName, + observed_hash: null, + snr: null, + }, + ...result.nodes, + ] + : []; + + return ( +
+
+

Trace

+

+ Build a repeater loop and trace it back to the local radio. The selectable hop list only + includes known full-key repeaters, but you can also add custom repeater prefixes. +

+
+ +
+
+
+

Repeater Hops

+

+ Search by name or key, then add repeaters in the order you want to traverse them. +

+ + setSearchQuery(event.target.value)} + placeholder="Search name or public key" + aria-label="Search repeaters" + className="mt-3" + /> +
+ {( + [ + ['alpha', 'Alpha'], + ['recent', 'Recent Heard'], + ['distance', 'Distance'], + ] as const + ).map(([value, label]) => ( + + ))} +
+ {sortMode === 'distance' && !canSortByDistance ? ( +

+ Distance sorting is using known repeater coordinates, but the local radio does not + currently have a valid location. +

+ ) : null} +
+ +
+ {filteredRepeaters.length === 0 ? ( +
+ No repeaters matched this search. +
+ ) : ( +
+ {filteredRepeaters.map((contact) => { + const displayName = getContactDisplayName( + contact.name, + contact.public_key, + contact.last_advert + ); + const distanceKm = getDistanceKm(contact, config); + const selectedCount = draftHops.filter( + (hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key + ).length; + return ( +
0 + ? 'border-primary/30 bg-primary/5' + : 'border-border bg-background hover:bg-accent' + )} + onClick={() => handleAddRepeater(contact.public_key)} + onKeyDown={handleKeyboardActivate} + > + +
+
{displayName}
+
+ {getShortKey(contact.public_key)} +
+ {sortMode === 'distance' && distanceKm !== null ? ( +
+ {distanceKm.toFixed(1)} km away +
+ ) : null} + {selectedCount > 0 ? ( +
+ Added {selectedCount} time{selectedCount === 1 ? '' : 's'} +
+ ) : null} +
+ +
+ ); + })} +
+ )} +
+
+ +
+
+
+

Trace Path

+

+ The first node is display-only. The terminal node is the local radio. +

+
+
+ + {draftHops.length === 0 ? ( +
+ Add at least one hop to build a trace loop. +
+ ) : ( + draftHops.map((hop, index) => { + const contact = + hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null; + const displayName = + hop.kind === 'repeater' + ? getContactDisplayName( + contact?.name, + hop.publicKey, + contact?.last_advert ?? null + ) + : 'Custom hop'; + const subtitle = + hop.kind === 'repeater' + ? getShortKey(hop.publicKey) + : `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`; + return ( +
+ + + + + + } + /> +
+ ); + }) + )} + +
+
+
+ {draftHops.length === 0 + ? 'No hops selected' + : `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`} +
+ +
+
+ +
+
+

+ Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''} +

+
+
+ {error ? ( +
+ {error} +
+ ) : null} + {!error && !result ? ( +
+ Send a trace to see the returned hop-by-hop SNR values. +
+ ) : null} + {result + ? resultNodes.map((node, index) => { + const title = + node.name || + (node.role === 'custom' + ? 'Custom hop' + : node.role === 'local' + ? localRadioName + : getShortKey(node.public_key)); + const subtitle = + node.role === 'custom' + ? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}` + : node.observed_hash && + node.public_key && + node.observed_hash.toLowerCase() !== + getShortKey(node.public_key).toLowerCase() + ? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}` + : getShortKey(node.public_key); + return ( +
+ +
+ ); + }) + : null} +
+
+
+
+ + + + + Custom path hop + + Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom + hop, all later custom hops must use the same byte width. + + + +
+
+
Hop width
+
+ {([1, 2, 4] as const).map((value) => { + const locked = customHopBytesLocked !== null && customHopBytesLocked !== value; + const active = (customHopBytesLocked ?? customHopBytesDraft) === value; + return ( + + ); + })} +
+ {customHopBytesLocked !== null ? ( +

+ Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace. +

+ ) : null} +
+ +
+ + + setCustomHopHexDraft(normalizeCustomHopHex(event.target.value)) + } + placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`} + /> +

+ Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters. +

+ {customHopError ? ( +
+ {customHopError} +
+ ) : null} +
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 2a7d6d8..9c886dd 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -192,8 +192,8 @@ export function SettingsLocalSection({

- Scales the app's typography for this browser only. The slider moves in 5% steps; - the number field accepts any value from 25% to 400%. + Scales the app's typography for this browser only. The slider moves in 5% steps; the + number field accepts any value from 25% to 400%.

diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index 167fd0f..5c11b80 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -275,7 +275,9 @@ interface UseConversationMessagesResult { } function isMessageConversation(conversation: Conversation | null): conversation is Conversation { - return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); + return ( + !!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type) + ); } function isActiveConversationMessage( diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index 5abd6fe..d0869a4 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -62,7 +62,6 @@ export function useConversationRouter({ // Only needs channels (fast path) - doesn't wait for contacts useEffect(() => { if (hasSetDefaultConversation.current || activeConversation) return; - if (channels.length === 0) return; const hashConv = parseHashSettingsSection() ? null : parseHashConversation(); @@ -92,6 +91,29 @@ export function useConversationRouter({ hasSetDefaultConversation.current = true; return; } + if (hashConv?.type === 'trace') { + setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' }); + hasSetDefaultConversation.current = true; + return; + } + + // No hash: optionally restore last-viewed non-data conversation if enabled on this device. + if (!hashConv && getReopenLastConversationEnabled()) { + const lastViewed = getLastViewedConversation(); + if ( + lastViewed && + (lastViewed.type === 'raw' || + lastViewed.type === 'map' || + lastViewed.type === 'visualizer' || + lastViewed.type === 'trace') + ) { + setActiveConversationState(lastViewed); + hasSetDefaultConversation.current = true; + return; + } + } + + if (channels.length === 0) return; // Handle channel hash (ID-first with legacy-name fallback) if (hashConv?.type === 'channel') { @@ -109,14 +131,6 @@ export function useConversationRouter({ // No hash: optionally restore last-viewed conversation if enabled on this device. if (!hashConv && getReopenLastConversationEnabled()) { const lastViewed = getLastViewedConversation(); - if ( - lastViewed && - (lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer') - ) { - setActiveConversationState(lastViewed); - hasSetDefaultConversation.current = true; - return; - } if (lastViewed?.type === 'channel') { const channel = channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) || diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 8b9f7cd..d0170af 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -195,6 +195,53 @@ describe('App startup hash resolution', () => { }); }); + it('restores the trace tool from the URL hash', async () => { + window.location.hash = '#trace'; + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent('trace:trace:Trace'); + } + }); + }); + + it('restores the trace tool from the URL hash even when channels are unavailable', async () => { + window.location.hash = '#trace'; + mocks.api.getChannels.mockResolvedValue([]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent('trace:trace:Trace'); + } + }); + }); + + it('reopens the last viewed trace tool even when channels are unavailable', async () => { + window.location.hash = ''; + localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1'); + localStorage.setItem( + LAST_VIEWED_CONVERSATION_KEY, + JSON.stringify({ + type: 'trace', + id: 'trace', + name: 'Trace', + }) + ); + mocks.api.getChannels.mockResolvedValue([]); + + render(); + + await waitFor(() => { + for (const node of screen.getAllByTestId('active-conversation')) { + expect(node).toHaveTextContent('trace:trace:Trace'); + } + }); + }); + it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => { const chatChannel = { key: '11111111111111111111111111111111', diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 1fcc9f8..7e81944 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({ VisualizerView: () =>
, })); +vi.mock('../components/TracePane', () => ({ + TracePane: () =>
, +})); + const config: RadioConfig = { public_key: 'aa'.repeat(32), name: 'Radio', @@ -141,6 +145,7 @@ function createProps(overrides: Partial {}), + onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })), onPathDiscovery: vi.fn(async () => { throw new Error('unused'); }), @@ -231,6 +236,23 @@ describe('ConversationPane', () => { }); }); + it('renders the trace tool pane for trace conversations', () => { + render( + + ); + + expect(screen.getByTestId('trace-pane')).toBeInTheDocument(); + expect(screen.queryByTestId('message-list')).not.toBeInTheDocument(); + }); + it('gates room chat behind room login controls until authenticated', async () => { render( ); - return { ...view, flightChannel, opsChannel, aliceName, roomName }; + return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation }; } function getSectionHeaderContainer(title: string): HTMLElement { @@ -306,6 +307,18 @@ describe('Sidebar section summaries', () => { expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + it('shows the trace tool row and selects it', () => { + const { onSelectConversation } = renderSidebar(); + + fireEvent.click(screen.getByText('Trace')); + + expect(onSelectConversation).toHaveBeenCalledWith({ + type: 'trace', + id: 'trace', + name: 'Trace', + }); + }); + it('sorts each section independently and persists per-section sort preferences', () => { const publicChannel = makeChannel('AA'.repeat(16), 'Public'); const zebraChannel = makeChannel('BB'.repeat(16), '#zebra'); diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx new file mode 100644 index 0000000..83d9cca --- /dev/null +++ b/frontend/src/test/tracePane.test.tsx @@ -0,0 +1,262 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { TracePane } from '../components/TracePane'; +import type { Contact, RadioConfig, RadioTraceResponse } from '../types'; +import { CONTACT_TYPE_REPEATER } from '../types'; + +function makeContact( + publicKey: string, + name: string | null, + type = CONTACT_TYPE_REPEATER, + overrides: Partial = {} +): Contact { + return { + public_key: publicKey, + name, + type, + flags: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + ...overrides, + }; +} + +const config: RadioConfig = { + public_key: 'ff'.repeat(32), + name: 'Base Radio', + lat: 10, + lon: 20, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: true, +}; + +describe('TracePane', () => { + it('shows only full-key repeaters and filters by name or key', () => { + render( + + ); + + expect(screen.getByText('Relay Alpha')).toBeInTheDocument(); + expect(screen.getByText('Relay Beta')).toBeInTheDocument(); + expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument(); + expect(screen.queryByText('Client Node')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } }); + expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument(); + expect(screen.getByText('Relay Beta')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } }); + expect(screen.getByText('Relay Alpha')).toBeInTheDocument(); + }); + + it('adds, reorders, removes, and sends a trace path with known repeaters', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 2, + timeout_seconds: 6, + nodes: [ + { + role: 'repeater', + public_key: relayB.public_key, + name: relayB.name, + observed_hash: relayB.public_key.slice(0, 8), + snr: 7.5, + }, + { + role: 'repeater', + public_key: relayA.public_key, + name: relayA.name, + observed_hash: relayA.public_key.slice(0, 8), + snr: 3.25, + }, + { + role: 'local', + public_key: config.public_key, + name: config.name, + observed_hash: null, + snr: 5.0, + }, + ], + }) + ); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + + expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(4, [ + { public_key: relayB.public_key }, + { public_key: relayA.public_key }, + ]); + }); + + expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument(); + expect(screen.getByText('+7.5 dB')).toBeInTheDocument(); + expect(screen.getByText('+5.0 dB')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i })); + expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i })); + expect(screen.getByText('No hops selected')).toBeInTheDocument(); + }); + + it('allows adding the same repeater multiple times from the picker row', () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + + expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByText('Added 2 times')).toBeInTheDocument(); + }); + + it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 2, + timeout_seconds: 4.5, + nodes: [ + { + role: 'custom', + public_key: null, + name: null, + observed_hash: 'ae', + snr: 4.0, + }, + { + role: 'repeater', + public_key: relayA.public_key, + name: relayA.name, + observed_hash: '11', + snr: 2.0, + }, + { + role: 'local', + public_key: config.public_key, + name: config.name, + observed_hash: null, + snr: 3.0, + }, + ], + }) + ); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Custom path' })); + fireEvent.click(screen.getByRole('button', { name: '1-byte' })); + fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' })); + + expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument(); + expect(screen.getByText('AE (1-byte)')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(1, [ + { hop_hex: 'ae' }, + { public_key: relayA.public_key }, + ]); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Custom path' })); + expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled(); + expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument(); + }); + + it('drops an in-flight result after the draft path changes', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + let resolveTrace: ((value: RadioTraceResponse) => void) | null = null; + const onRunTracePath = vi.fn( + () => + new Promise((resolve) => { + resolveTrace = resolve; + }) + ); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]); + }); + + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + + expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled(); + + await act(async () => { + resolveTrace?.({ + path_len: 1, + timeout_seconds: 6, + nodes: [ + { + role: 'repeater', + public_key: relayA.public_key, + name: relayA.name, + observed_hash: relayA.public_key.slice(0, 8), + snr: 7.5, + }, + { + role: 'local', + public_key: config.public_key, + name: config.name, + observed_hash: null, + snr: 5.0, + }, + ], + }); + }); + + expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument(); + expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument(); + expect( + screen.getByText('Send a trace to see the returned hop-by-hop SNR values.') + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts index 8e3cd1f..57cb8f9 100644 --- a/frontend/src/test/urlHash.test.ts +++ b/frontend/src/test/urlHash.test.ts @@ -52,6 +52,14 @@ describe('parseHashConversation', () => { expect(result).toEqual({ type: 'map', name: 'map' }); }); + it('parses #trace as trace type', () => { + window.location.hash = '#trace'; + + const result = parseHashConversation(); + + expect(result).toEqual({ type: 'trace', name: 'trace' }); + }); + it('parses #map/focus/PUBKEY with focus key', () => { window.location.hash = '#map/focus/ABCD1234'; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d54a573..441d8a8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -286,7 +286,7 @@ export interface ResendChannelMessageResponse { message?: Message; } -type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search'; +type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace'; export interface Conversation { type: ConversationType; @@ -474,6 +474,25 @@ export interface TraceResponse { path_len: number; } +export interface RadioTraceNode { + role: 'repeater' | 'custom' | 'local'; + public_key: string | null; + name: string | null; + observed_hash: string | null; + snr: number | null; +} + +export interface RadioTraceHopRequest { + public_key?: string | null; + hop_hex?: string | null; +} + +export interface RadioTraceResponse { + path_len: number; + timeout_seconds: number; + nodes: RadioTraceNode[]; +} + export interface PathDiscoveryRoute { path: string; path_len: number; diff --git a/frontend/src/utils/lastViewedConversation.ts b/frontend/src/utils/lastViewedConversation.ts index 1bd7967..cd661ee 100644 --- a/frontend/src/utils/lastViewedConversation.ts +++ b/frontend/src/utils/lastViewedConversation.ts @@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash'; export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation'; export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation'; -const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer']; +const SUPPORTED_TYPES: Conversation['type'][] = [ + 'contact', + 'channel', + 'raw', + 'map', + 'visualizer', + 'trace', +]; function isSupportedType(value: unknown): value is Conversation['type'] { return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']); @@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void { saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' }); return; } + if (hashConversation.type === 'trace') { + saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' }); + return; + } saveLastViewedConversation({ type: hashConversation.type, diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index 929fe5a..0e82243 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey'; import type { SettingsSection } from '../components/settings/settingsConstants'; interface ParsedHashConversation { - type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search'; + type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace'; /** Conversation identity token (channel key or contact public key, or legacy name token) */ name: string; /** Optional human-readable label segment (ignored for identity resolution) */ @@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null { return { type: 'search', name: 'search' }; } + if (hash === 'trace') { + return { type: 'trace', name: 'trace' }; + } + // Check for map with focus: #map/focus/{pubkey_prefix} if (hash.startsWith('map/focus/')) { const focusKey = hash.slice('map/focus/'.length); @@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string { if (conv.type === 'map') return '#map'; if (conv.type === 'visualizer') return '#visualizer'; if (conv.type === 'search') return '#search'; + if (conv.type === 'trace') return '#trace'; // Use immutable IDs for identity, append readable label for UX. if (conv.type === 'channel') { diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index b7eba08..a3a9ac0 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -9,7 +9,7 @@ from fastapi import HTTPException from meshcore import EventType from pydantic import ValidationError -from app.models import Contact +from app.models import CONTACT_TYPE_REPEATER, Contact, RadioTraceHopRequest, RadioTraceRequest from app.radio import RadioManager, radio_manager from app.routers.radio import ( PrivateKeyUpdate, @@ -25,6 +25,7 @@ from app.routers.radio import ( reconnect_radio, send_advertisement, set_private_key, + trace_path, update_radio_config, ) from app.services.radio_runtime import RadioRuntime @@ -524,6 +525,223 @@ class TestDiscoverMesh: mock_upsert.assert_not_awaited() mock_broadcast.assert_not_called() + +class TestTracePath: + @pytest.mark.asyncio + async def test_returns_resolved_nodes_for_multi_hop_trace(self): + mc = _mock_meshcore_with_info() + repeater_a = Contact( + public_key="11" * 32, + name="Relay Alpha", + type=CONTACT_TYPE_REPEATER, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + repeater_b = Contact( + public_key="22" * 32, + name="Relay Beta", + type=CONTACT_TYPE_REPEATER, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 4000}) + ) + mc.wait_for_event = AsyncMock( + return_value=MagicMock( + payload={ + "path_len": 2, + "path": [ + {"hash": "11111111", "snr": 7.5}, + {"hash": "22222222", "snr": 3.25}, + {"snr": 5.0}, + ], + } + ) + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch( + "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock + ) as mock_get, + patch("app.routers.radio.radio_manager") as mock_rm, + ): + mock_get.side_effect = [repeater_a, repeater_b] + mock_rm.radio_operation = _noop_radio_operation(mc) + response = await trace_path( + RadioTraceRequest( + hop_hash_bytes=4, + hops=[ + RadioTraceHopRequest(public_key=repeater_a.public_key), + RadioTraceHopRequest(public_key=repeater_b.public_key), + ], + ) + ) + + mc.commands.send_trace.assert_awaited_once_with( + path="11111111,22222222", + tag=ANY, + flags=2, + ) + mc.wait_for_event.assert_awaited_once() + assert response.path_len == 2 + assert response.nodes[0].name == "Relay Alpha" + assert response.nodes[0].snr == 7.5 + assert response.nodes[1].name == "Relay Beta" + assert response.nodes[1].observed_hash == "22222222" + assert response.nodes[2].role == "local" + assert response.nodes[2].public_key == "aa" * 32 + assert response.nodes[2].observed_hash is None + assert response.nodes[2].snr == 5.0 + + @pytest.mark.asyncio + async def test_rejects_non_repeater_nodes(self): + mc = _mock_meshcore_with_info() + non_repeater = Contact( + public_key="33" * 32, + name="Client", + type=1, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch( + "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock + ) as mock_get, + ): + mock_get.return_value = non_repeater + with pytest.raises(HTTPException) as exc: + await trace_path( + RadioTraceRequest( + hop_hash_bytes=4, + hops=[RadioTraceHopRequest(public_key=non_repeater.public_key)], + ) + ) + + assert exc.value.status_code == 400 + assert "not a repeater" in exc.value.detail + + @pytest.mark.asyncio + async def test_returns_504_when_no_trace_response_is_heard(self): + mc = _mock_meshcore_with_info() + repeater = Contact( + public_key="44" * 32, + name="Relay", + type=CONTACT_TYPE_REPEATER, + flags=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, + last_advert=None, + lat=None, + lon=None, + last_seen=None, + on_radio=False, + last_contacted=None, + last_read_at=None, + first_seen=None, + ) + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 1000}) + ) + mc.wait_for_event = AsyncMock(return_value=None) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch( + "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock + ) as mock_get, + patch("app.routers.radio.radio_manager") as mock_rm, + ): + mock_get.return_value = repeater + mock_rm.radio_operation = _noop_radio_operation(mc) + with pytest.raises(HTTPException) as exc: + await trace_path( + RadioTraceRequest( + hop_hash_bytes=4, + hops=[RadioTraceHopRequest(public_key=repeater.public_key)], + ) + ) + + assert exc.value.status_code == 504 + assert "No trace response heard" in exc.value.detail + + @pytest.mark.asyncio + async def test_supports_custom_hops_with_shorter_hash_width(self): + mc = _mock_meshcore_with_info() + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 2500}) + ) + mc.wait_for_event = AsyncMock( + return_value=MagicMock( + payload={ + "path_len": 2, + "path": [ + {"hash": "ae", "snr": 4.0}, + {"hash": "bf", "snr": 2.5}, + {"snr": 3.0}, + ], + } + ) + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.radio.radio_manager") as mock_rm, + ): + mock_rm.radio_operation = _noop_radio_operation(mc) + response = await trace_path( + RadioTraceRequest( + hop_hash_bytes=1, + hops=[ + RadioTraceHopRequest(hop_hex="ae"), + RadioTraceHopRequest(hop_hex="bf"), + ], + ) + ) + + mc.commands.send_trace.assert_awaited_once_with(path="ae,bf", tag=ANY, flags=0) + assert response.nodes[0].role == "custom" + assert response.nodes[0].observed_hash == "ae" + assert response.nodes[1].role == "custom" + assert response.nodes[1].observed_hash == "bf" + @pytest.mark.asyncio async def test_discovers_all_supported_types(self): mc = _mock_meshcore_with_info()