mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 18:01:22 +02: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):
|
class CommandRequest(BaseModel):
|
||||||
"""Request to send a CLI command to a repeater."""
|
"""Request to send a CLI command to a repeater."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
@@ -15,6 +16,7 @@ from app.models import (
|
|||||||
NeighborInfo,
|
NeighborInfo,
|
||||||
TelemetryRequest,
|
TelemetryRequest,
|
||||||
TelemetryResponse,
|
TelemetryResponse,
|
||||||
|
TraceResponse,
|
||||||
)
|
)
|
||||||
from app.packet_processor import start_historical_dm_decryption
|
from app.packet_processor import start_historical_dm_decryption
|
||||||
from app.radio import radio_manager
|
from app.radio import radio_manager
|
||||||
@@ -532,3 +534,61 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
|
|||||||
finally:
|
finally:
|
||||||
# Always restart auto-fetch, even if an error occurred
|
# Always restart auto-fetch, even if an error occurred
|
||||||
await mc.start_auto_message_fetching()
|
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)
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+21
-21
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -693,6 +693,22 @@ export function App() {
|
|||||||
[fetchUndecryptedCount]
|
[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
|
// Handle sort order change via API with optimistic update
|
||||||
const handleSortOrderChange = useCallback(
|
const handleSortOrderChange = useCallback(
|
||||||
async (order: 'recent' | 'alpha') => {
|
async (order: 'recent' | 'alpha') => {
|
||||||
@@ -879,6 +895,16 @@ export function App() {
|
|||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<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 */}
|
{/* Favorite button */}
|
||||||
{(activeConversation.type === 'channel' ||
|
{(activeConversation.type === 'channel' ||
|
||||||
activeConversation.type === 'contact') && (
|
activeConversation.type === 'contact') && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
TelemetryResponse,
|
TelemetryResponse,
|
||||||
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -114,6 +115,10 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ command }),
|
body: JSON.stringify({ command }),
|
||||||
}),
|
}),
|
||||||
|
requestTrace: (publicKey: string) =>
|
||||||
|
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
// Channels
|
// Channels
|
||||||
getChannels: () => fetchJson<Channel[]>('/channels'),
|
getChannels: () => fetchJson<Channel[]>('/channels'),
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ export interface CommandResponse {
|
|||||||
sender_timestamp: number | null;
|
sender_timestamp: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TraceResponse {
|
||||||
|
remote_snr: number | null;
|
||||||
|
local_snr: number | null;
|
||||||
|
path_len: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UnreadCounts {
|
export interface UnreadCounts {
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
mentions: Record<string, boolean>;
|
mentions: Record<string, boolean>;
|
||||||
|
|||||||
Reference in New Issue
Block a user