Add one hop trace

This commit is contained in:
Jack Kingsman
2026-02-04 15:29:33 -08:00
parent 878626b440
commit fcbab3bf72
11 changed files with 135 additions and 26 deletions

View File

@@ -195,6 +195,18 @@ class TelemetryResponse(BaseModel):
)
class TraceResponse(BaseModel):
"""Result of a direct (zero-hop) trace to a contact."""
remote_snr: float | None = Field(
default=None, description="SNR at which the target heard us (dB)"
)
local_snr: float | None = Field(
default=None, description="SNR at which we heard the target on the bounce-back (dB)"
)
path_len: int = Field(description="Number of hops in the trace path")
class CommandRequest(BaseModel):
"""Request to send a CLI command to a repeater."""

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import random
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType
@@ -15,6 +16,7 @@ from app.models import (
NeighborInfo,
TelemetryRequest,
TelemetryResponse,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.radio import radio_manager
@@ -532,3 +534,61 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
finally:
# Always restart auto-fetch, even if an error occurred
await mc.start_auto_message_fetching()
@router.post("/{public_key}/trace", response_model=TraceResponse)
async def request_trace(public_key: str) -> TraceResponse:
"""Send a direct (zero-hop) trace to a contact and wait for the result.
This sends a trace with no path (direct only, no repeaters) and waits
up to 15 seconds for the TRACE_DATA response.
"""
mc = require_connected()
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
tag = random.randint(1, 0xFFFFFFFF)
# Note: unlike command/telemetry endpoints, trace does NOT need
# stop/start_auto_message_fetching because the response arrives as a
# TRACE_DATA event through the reader loop, not via get_msg().
async with pause_polling():
# Ensure contact is on radio so the trace can reach them
await mc.commands.add_contact(contact.to_radio_dict())
logger.info("Sending direct trace to %s (tag=%d)", contact.public_key[:12], tag)
result = await mc.commands.send_trace(path=None, tag=tag)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
# Wait for the matching TRACE_DATA event
event = await mc.wait_for_event(
EventType.TRACE_DATA,
attribute_filters={"tag": tag},
timeout=15,
)
if event is None:
raise HTTPException(status_code=504, detail="No trace response heard")
trace = event.payload
path = trace.get("path", [])
path_len = trace.get("path_len", 0)
# remote_snr: first entry in path (what the target heard us at)
remote_snr = path[0]["snr"] if path else None
# local_snr: last entry in path (what we heard them at on the bounce-back)
local_snr = path[-1]["snr"] if path else None
logger.info(
"Trace result for %s: path_len=%d, remote_snr=%s, local_snr=%s",
contact.public_key[:12],
path_len,
remote_snr,
local_snr,
)
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CWnjp-zX.js"></script>
<script type="module" crossorigin src="/assets/index-Bns2erWl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
</head>
<body>

View File

@@ -693,6 +693,22 @@ export function App() {
[fetchUndecryptedCount]
);
// Handle direct trace request
const handleTrace = useCallback(async () => {
if (!activeConversation || activeConversation.type !== 'contact') return;
toast('Trace started...');
try {
const result = await api.requestTrace(activeConversation.id);
const parts: string[] = [];
if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`);
if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`);
const detail = parts.join(', ');
toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!');
} catch {
toast.error('No trace response heard');
}
}, [activeConversation]);
// Handle sort order change via API with optimistic update
const handleSortOrderChange = useCallback(
async (order: 'recent' | 'alpha') => {
@@ -879,6 +895,16 @@ export function App() {
})()}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Direct trace button (contacts only) */}
{activeConversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
onClick={handleTrace}
title="Direct Trace"
>
&#x1F6CE;
</button>
)}
{/* Favorite button */}
{(activeConversation.type === 'channel' ||
activeConversation.type === 'contact') && (

View File

@@ -13,6 +13,7 @@ import type {
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
TraceResponse,
UnreadCounts,
} from './types';
@@ -114,6 +115,10 @@ export const api = {
method: 'POST',
body: JSON.stringify({ command }),
}),
requestTrace: (publicKey: string) =>
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
method: 'POST',
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),

View File

@@ -199,6 +199,12 @@ export interface CommandResponse {
sender_timestamp: number | null;
}
export interface TraceResponse {
remote_snr: number | null;
local_snr: number | null;
path_len: number;
}
export interface UnreadCounts {
counts: Record<string, number>;
mentions: Record<string, boolean>;