Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history

This commit is contained in:
Gnome Adrift
2026-03-31 09:11:49 -07:00
86 changed files with 4450 additions and 724 deletions
+14 -6
View File
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contacts", tags=["contacts"])
TRACE_HASH_BYTES = 4
TRACE_FLAGS_4BYTE = 2
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
sample = ", ".join(key[:12] for key in err.matches[:2])
return (
@@ -373,17 +377,17 @@ async def delete_contact(public_key: str) -> dict:
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.
The trace path contains the contact's 4-byte pubkey hash as the sole hop
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
than the radio's normal path_hash_mode setting.
"""
require_connected()
contact = await _resolve_contact_or_404(public_key)
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]
# Use a 4-byte contact hash for low-collision direct trace targeting.
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
# from the reader loop, not via get_msg().
@@ -394,7 +398,11 @@ async def request_trace(public_key: str) -> TraceResponse:
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)
result = await mc.commands.send_trace(
path=contact_hash,
tag=tag,
flags=TRACE_FLAGS_4BYTE,
)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
+4 -4
View File
@@ -265,7 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
statistics = await StatisticsRepository.get_all()
message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
@@ -291,9 +291,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
},
),
database=DebugDatabaseInfo(
total_dms=statistics["total_dms"],
total_channel_messages=statistics["total_channel_messages"],
total_outgoing=statistics["total_outgoing"],
total_dms=message_totals["total_dms"],
total_channel_messages=message_totals["total_channel_messages"],
total_outgoing=message_totals["total_outgoing"],
),
radio_probe=radio_probe,
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
+1 -2
View File
@@ -210,8 +210,7 @@ async def decrypt_historical_packets(
except ValueError:
raise _bad_request("Invalid hex string for contact public key") from None
packets = await RawPacketRepository.get_undecrypted_text_messages()
count = len(packets)
count = await RawPacketRepository.count_undecrypted_text_messages()
if count == 0:
return DecryptResult(
started=False,
+222
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import random
import time
from contextlib import suppress
from typing import Literal, TypeAlias
from fastapi import APIRouter, HTTPException
@@ -10,14 +11,20 @@ from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
CONTACT_TYPE_REPEATER,
ContactUpsert,
RadioDiscoveryRequest,
RadioDiscoveryResponse,
RadioDiscoveryResult,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceRequest,
RadioTraceResponse,
)
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
@@ -44,6 +51,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
2: "repeater",
4: "sensor",
}
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
TRACE_TIMEOUT_MIN_SECONDS = 5.0
TRACE_TIMEOUT_MAX_SECONDS = 30.0
TRACE_TIMEOUT_MARGIN = 1.2
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
@@ -197,9 +210,118 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
on_radio=False,
)
await ContactRepository.upsert(contact)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=result.public_key,
log=logger,
)
created = await ContactRepository.get_by_key(result.public_key)
if created is not None:
broadcast_event("contact", created.model_dump())
for old_key in promoted_keys:
broadcast_event("contact_deleted", {"public_key": old_key})
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
"""Resolve known contact names for discovery results from the DB."""
for result in results:
contact = await ContactRepository.get_by_key(result.public_key)
if contact is not None and contact.name:
result.name = contact.name
def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str:
return public_key[: hop_hash_bytes * 2].lower()
def _trace_timeout_seconds(send_result: object) -> float:
payload = getattr(send_result, "payload", None) or {}
suggested_timeout = payload.get("suggested_timeout")
try:
if suggested_timeout is None:
raise TypeError
timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN
except (TypeError, ValueError):
timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS
return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds))
async def _resolve_trace_hops(
hops: list[RadioTraceHopRequest], hop_hash_bytes: int
) -> tuple[list[RadioTraceNode], list[str]]:
trace_nodes: list[RadioTraceNode] = []
requested_hashes: list[str] = []
expected_hex_len = hop_hash_bytes * 2
for hop in hops:
public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None
hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None
if bool(public_key) == bool(hop_hex):
raise HTTPException(
status_code=400,
detail="Each trace hop must provide exactly one of public_key or hop_hex",
)
if public_key:
if len(public_key) != 64:
raise HTTPException(
status_code=400,
detail="Trace repeater keys must be full 64-character public keys",
)
try:
bytes.fromhex(public_key)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail="Trace repeater keys must be valid hex public keys",
) from exc
contact = await ContactRepository.get_by_key(public_key)
if contact is None:
raise HTTPException(
status_code=404, detail=f"Trace repeater not found: {public_key}"
)
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Trace node is not a repeater: {public_key[:12]}",
)
requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes))
trace_nodes.append(
RadioTraceNode(
role="repeater",
public_key=contact.public_key,
name=contact.name,
observed_hash=None,
snr=None,
)
)
continue
assert hop_hex is not None
if len(hop_hex) != expected_hex_len:
raise HTTPException(
status_code=400,
detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters",
)
try:
bytes.fromhex(hop_hex)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail="Custom trace hops must be valid hex",
) from exc
requested_hashes.append(hop_hex)
trace_nodes.append(
RadioTraceNode(
role="custom",
public_key=None,
name=None,
observed_hash=hop_hex,
snr=None,
)
)
return trace_nodes, requested_hashes
@router.get("/config", response_model=RadioConfigResponse)
@@ -365,6 +487,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
),
)
await _persist_new_discovery_contacts(results)
await _attach_known_names(results)
return RadioDiscoveryResponse(
target=request.target,
duration_seconds=DISCOVERY_WINDOW_SECONDS,
@@ -372,6 +495,105 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
)
@router.post("/trace", response_model=RadioTraceResponse)
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
require_connected()
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
tag = random.randint(1, 0xFFFFFFFF)
trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes]
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
if len(local_public_key) != 64:
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
local_name = (mc.self_info or {}).get("name")
response_task = asyncio.create_task(
mc.wait_for_event(
EventType.TRACE_DATA,
attribute_filters={"tag": tag},
timeout=TRACE_WAIT_TIMEOUT_SECONDS,
)
)
try:
send_result = await mc.commands.send_trace(
path=",".join(requested_hashes),
tag=tag,
flags=trace_flags,
)
if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to send trace")
timeout_seconds = _trace_timeout_seconds(send_result)
try:
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
except asyncio.TimeoutError as exc:
raise HTTPException(status_code=504, detail="No trace response heard") from exc
finally:
if not response_task.done():
response_task.cancel()
with suppress(asyncio.CancelledError):
await response_task
if event is None:
raise HTTPException(status_code=504, detail="No trace response heard")
payload = event.payload if isinstance(event.payload, dict) else {}
path_len = payload.get("path_len")
if not isinstance(path_len, int):
raise HTTPException(status_code=500, detail="Trace response was malformed")
raw_path = payload.get("path")
path_nodes = raw_path if isinstance(raw_path, list) else []
final_local_node = (
path_nodes[-1]
if path_nodes
and isinstance(path_nodes[-1], dict)
and not isinstance(path_nodes[-1].get("hash"), str)
else None
)
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
if len(hashed_nodes) < len(trace_nodes):
raise HTTPException(status_code=500, detail="Trace response was incomplete")
nodes: list[RadioTraceNode] = []
for index, trace_node in enumerate(trace_nodes):
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
nodes.append(
RadioTraceNode(
role=trace_node.role,
public_key=trace_node.public_key,
name=trace_node.name,
observed_hash=(
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
),
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
)
)
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
nodes.append(
RadioTraceNode(
role="local",
public_key=local_public_key,
name=local_name if isinstance(local_name, str) and local_name else None,
observed_hash=None,
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
)
)
return RadioTraceResponse(
path_len=path_len,
timeout_seconds=timeout_seconds,
nodes=nodes,
)
async def _attempt_reconnect() -> dict:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()
-57
View File
@@ -1,4 +1,3 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
@@ -31,7 +30,6 @@ from app.models import (
from app.repository import ContactRepository, RepeaterTelemetryRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
_monotonic,
batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection,
@@ -40,9 +38,6 @@ from app.routers.server_control import (
)
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
# ACL permission level names
@@ -60,58 +55,6 @@ def _extract_response_text(event) -> str:
return extract_response_text(event)
async def _fetch_repeater_response(
mc,
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
deadline = _monotonic() + timeout
while _monotonic() < deadline:
try:
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
await asyncio.sleep(1.0)
continue
if result.type == EventType.NO_MORE_MSGS:
await asyncio.sleep(1.0)
continue
if result.type == EventType.ERROR:
logger.debug("get_msg() error: %s", result.payload)
await asyncio.sleep(1.0)
continue
if result.type == EventType.CONTACT_MSG_RECV:
msg_prefix = result.payload.get("pubkey_prefix", "")
txt_type = result.payload.get("txt_type", 0)
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
msg_prefix,
txt_type,
target_pubkey_prefix,
)
continue
if result.type == EventType.CHANNEL_MSG_RECV:
logger.debug(
"Skipping channel message (channel_idx=%s) during repeater fetch",
result.payload.get("channel_idx"),
)
continue
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
return None
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
return await prepare_authenticated_contact_connection(
mc,
+5 -2
View File
@@ -13,6 +13,7 @@ from app.models import (
Contact,
RepeaterLoginResponse,
)
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
from app.routers.contacts import _ensure_on_radio
from app.services.radio_runtime import radio_runtime as radio_manager
@@ -115,18 +116,20 @@ async def fetch_contact_cli_response(
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
msg_prefix,
txt_type,
target_pubkey_prefix,
)
await _store_pending_direct_message(result)
continue
if result.type == EventType.CHANNEL_MSG_RECV:
logger.debug(
"Skipping channel message (channel_idx=%s) during CLI fetch",
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
result.payload.get("channel_idx"),
)
await _store_pending_channel_message(mc, result.payload)
continue
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.models import StatisticsResponse
from app.repository import StatisticsRepository
from app.services.radio_noise_floor import get_noise_floor_history
router = APIRouter(prefix="/statistics", tags=["statistics"])
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
@router.get("", response_model=StatisticsResponse)
async def get_statistics() -> StatisticsResponse:
data = await StatisticsRepository.get_all()
data["noise_floor_24h"] = await get_noise_floor_history()
return StatisticsResponse(**data)