mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add one hop trace
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
1
frontend/dist/assets/index-Bns2erWl.js.map
vendored
Normal file
1
frontend/dist/assets/index-Bns2erWl.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-CWnjp-zX.js.map
vendored
1
frontend/dist/assets/index-CWnjp-zX.js.map
vendored
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
🛎
|
||||
</button>
|
||||
)}
|
||||
{/* Favorite button */}
|
||||
{(activeConversation.type === 'channel' ||
|
||||
activeConversation.type === 'contact') && (
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user