forked from iarv/Remote-Terminal-for-MeshCore
Compare commits
2 Commits
support-tc
...
single-nod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bccfb957 | ||
|
|
fcbab3bf72 |
@@ -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,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
1
frontend/dist/assets/index-AkYO4-QT.js.map
vendored
Normal file
1
frontend/dist/assets/index-AkYO4-QT.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-AkYO4-QT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
🛎
|
||||
</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