2 Commits

Author SHA1 Message Date
Jack Kingsman
31bccfb957 Add error bubble-up 2026-02-04 19:27:45 -08:00
Jack Kingsman
fcbab3bf72 Add one hop trace 2026-02-04 15:40:37 -08:00
11 changed files with 142 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,66 @@ 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 single-hop trace to a contact and wait for the result.
The trace path contains the contact's 1-byte pubkey hash as the sole hop
(no intermediate repeaters). The radio firmware requires at least one
node in the path.
"""
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)
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
contact_hash = contact.public_key[:2]
# 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 trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
)
result = await mc.commands.send_trace(path=contact_hash, 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-AkYO4-QT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
</head>
<body>

View File

@@ -693,6 +693,24 @@ 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 (err) {
toast.error('Trace failed', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
}, [activeConversation]);
// Handle sort order change via API with optimistic update
const handleSortOrderChange = useCallback(
async (order: 'recent' | 'alpha') => {
@@ -879,6 +897,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>;