mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
284 lines
10 KiB
Python
284 lines
10 KiB
Python
import hashlib
|
|
import logging
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter
|
|
from meshcore import EventType
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.config import get_recent_log_lines, settings
|
|
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
|
from app.repository import MessageRepository
|
|
from app.routers.health import HealthResponse, build_health_data
|
|
from app.services.radio_runtime import radio_runtime
|
|
from app.version_info import get_app_build_info, git_output
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["debug"])
|
|
|
|
LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW"
|
|
LOG_COPY_BOUNDARY_LINE = "-" * 64
|
|
LOG_COPY_BOUNDARY_PREFIX = [
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_MESSAGE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
LOG_COPY_BOUNDARY_LINE,
|
|
]
|
|
|
|
|
|
class DebugApplicationInfo(BaseModel):
|
|
version: str
|
|
version_source: str
|
|
commit_hash: str | None = None
|
|
commit_source: str | None = None
|
|
git_branch: str | None = None
|
|
git_dirty: bool | None = None
|
|
python_version: str
|
|
|
|
|
|
class DebugRuntimeInfo(BaseModel):
|
|
connection_info: str | None = None
|
|
connection_desired: bool
|
|
setup_in_progress: bool
|
|
setup_complete: bool
|
|
channels_with_incoming_messages: int
|
|
max_channels: int
|
|
path_hash_mode: int
|
|
path_hash_mode_supported: bool
|
|
channel_slot_reuse_enabled: bool
|
|
channel_send_cache_capacity: int
|
|
remediation_flags: dict[str, bool]
|
|
|
|
|
|
class DebugContactAudit(BaseModel):
|
|
expected_and_found: int
|
|
expected_but_not_found: list[str]
|
|
found_but_not_expected: list[str]
|
|
|
|
|
|
class DebugChannelSlotMismatch(BaseModel):
|
|
slot_number: int
|
|
expected_sha256_of_room_key: str | None = None
|
|
actual_sha256_of_room_key: str | None = None
|
|
|
|
|
|
class DebugChannelAudit(BaseModel):
|
|
matched_slots: int
|
|
wrong_slots: list[DebugChannelSlotMismatch]
|
|
|
|
|
|
class DebugRadioProbe(BaseModel):
|
|
performed: bool
|
|
errors: list[str] = Field(default_factory=list)
|
|
self_info: dict[str, Any] | None = None
|
|
device_info: dict[str, Any] | None = None
|
|
stats_core: dict[str, Any] | None = None
|
|
stats_radio: dict[str, Any] | None = None
|
|
contacts: DebugContactAudit | None = None
|
|
channels: DebugChannelAudit | None = None
|
|
|
|
|
|
class DebugSnapshotResponse(BaseModel):
|
|
captured_at: str
|
|
application: DebugApplicationInfo
|
|
health: HealthResponse
|
|
runtime: DebugRuntimeInfo
|
|
radio_probe: DebugRadioProbe
|
|
logs: list[str]
|
|
|
|
|
|
def _build_application_info() -> DebugApplicationInfo:
|
|
build_info = get_app_build_info()
|
|
dirty_output = git_output("status", "--porcelain")
|
|
return DebugApplicationInfo(
|
|
version=build_info.version,
|
|
version_source=build_info.version_source,
|
|
commit_hash=build_info.commit_hash,
|
|
commit_source=build_info.commit_source,
|
|
git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"),
|
|
git_dirty=(dirty_output is not None and dirty_output != ""),
|
|
python_version=sys.version.split()[0],
|
|
)
|
|
|
|
|
|
def _event_type_name(event: Any) -> str:
|
|
event_type = getattr(event, "type", None)
|
|
return getattr(event_type, "name", str(event_type))
|
|
|
|
|
|
def _sha256_hex(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _normalize_channel_secret(payload: dict[str, Any]) -> bytes:
|
|
secret = payload.get("channel_secret", b"")
|
|
if isinstance(secret, bytes):
|
|
return secret
|
|
return bytes(secret)
|
|
|
|
|
|
def _is_empty_channel_payload(payload: dict[str, Any]) -> bool:
|
|
name = payload.get("channel_name", "")
|
|
return not name or name == "\x00" * len(name)
|
|
|
|
|
|
def _observed_channel_key(event: Any) -> str | None:
|
|
if getattr(event, "type", None) != EventType.CHANNEL_INFO:
|
|
return None
|
|
|
|
payload = event.payload or {}
|
|
if _is_empty_channel_payload(payload):
|
|
return None
|
|
|
|
return _normalize_channel_secret(payload).hex().upper()
|
|
|
|
|
|
def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
|
if not device_info or "max_channels" not in device_info:
|
|
return None
|
|
try:
|
|
return int(device_info["max_channels"])
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
async def _build_contact_audit(
|
|
observed_contacts_payload: dict[str, dict[str, Any]],
|
|
) -> DebugContactAudit:
|
|
expected_contacts = await get_contacts_selected_for_radio_sync()
|
|
expected_keys = {contact.public_key.lower() for contact in expected_contacts}
|
|
observed_keys = {public_key.lower() for public_key in observed_contacts_payload}
|
|
|
|
return DebugContactAudit(
|
|
expected_and_found=len(expected_keys & observed_keys),
|
|
expected_but_not_found=sorted(_sha256_hex(key) for key in (expected_keys - observed_keys)),
|
|
found_but_not_expected=sorted(_sha256_hex(key) for key in (observed_keys - expected_keys)),
|
|
)
|
|
|
|
|
|
async def _build_channel_audit(mc: Any, max_channels: int | None = None) -> DebugChannelAudit:
|
|
cache_key_by_slot = {
|
|
slot: channel_key for channel_key, slot in radio_runtime.get_channel_send_cache_snapshot()
|
|
}
|
|
|
|
matched_slots = 0
|
|
wrong_slots: list[DebugChannelSlotMismatch] = []
|
|
for slot in range(get_radio_channel_limit(max_channels)):
|
|
event = await mc.commands.get_channel(slot)
|
|
expected_key = cache_key_by_slot.get(slot)
|
|
observed_key = _observed_channel_key(event)
|
|
if expected_key == observed_key:
|
|
matched_slots += 1
|
|
continue
|
|
wrong_slots.append(
|
|
DebugChannelSlotMismatch(
|
|
slot_number=slot,
|
|
expected_sha256_of_room_key=_sha256_hex(expected_key) if expected_key else None,
|
|
actual_sha256_of_room_key=_sha256_hex(observed_key) if observed_key else None,
|
|
)
|
|
)
|
|
|
|
return DebugChannelAudit(
|
|
matched_slots=matched_slots,
|
|
wrong_slots=wrong_slots,
|
|
)
|
|
|
|
|
|
async def _probe_radio() -> DebugRadioProbe:
|
|
if not radio_runtime.is_connected:
|
|
return DebugRadioProbe(performed=False, errors=["Radio not connected"])
|
|
|
|
errors: list[str] = []
|
|
try:
|
|
async with radio_runtime.radio_operation(
|
|
"debug_support_snapshot",
|
|
suspend_auto_fetch=True,
|
|
) as mc:
|
|
device_info = None
|
|
stats_core = None
|
|
stats_radio = None
|
|
|
|
device_event = await mc.commands.send_device_query()
|
|
if getattr(device_event, "type", None) == EventType.DEVICE_INFO:
|
|
device_info = device_event.payload
|
|
else:
|
|
errors.append(f"send_device_query returned {_event_type_name(device_event)}")
|
|
|
|
core_event = await mc.commands.get_stats_core()
|
|
if getattr(core_event, "type", None) == EventType.STATS_CORE:
|
|
stats_core = core_event.payload
|
|
else:
|
|
errors.append(f"get_stats_core returned {_event_type_name(core_event)}")
|
|
|
|
radio_event = await mc.commands.get_stats_radio()
|
|
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
|
|
stats_radio = radio_event.payload
|
|
else:
|
|
errors.append(f"get_stats_radio returned {_event_type_name(radio_event)}")
|
|
|
|
contacts_event = await mc.commands.get_contacts()
|
|
observed_contacts_payload: dict[str, dict[str, Any]] = {}
|
|
if getattr(contacts_event, "type", None) != EventType.ERROR:
|
|
observed_contacts_payload = contacts_event.payload or {}
|
|
else:
|
|
errors.append(f"get_contacts returned {_event_type_name(contacts_event)}")
|
|
|
|
return DebugRadioProbe(
|
|
performed=True,
|
|
errors=errors,
|
|
self_info=dict(mc.self_info or {}),
|
|
device_info=device_info,
|
|
stats_core=stats_core,
|
|
stats_radio=stats_radio,
|
|
contacts=await _build_contact_audit(observed_contacts_payload),
|
|
channels=await _build_channel_audit(
|
|
mc,
|
|
max_channels=_coerce_live_max_channels(device_info),
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Debug support snapshot radio probe failed: %s", exc, exc_info=True)
|
|
errors.append(str(exc))
|
|
return DebugRadioProbe(performed=False, errors=errors)
|
|
|
|
|
|
@router.get("/debug", response_model=DebugSnapshotResponse)
|
|
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)
|
|
radio_probe = await _probe_radio()
|
|
channels_with_incoming_messages = (
|
|
await MessageRepository.count_channels_with_incoming_messages()
|
|
)
|
|
return DebugSnapshotResponse(
|
|
captured_at=datetime.now(timezone.utc).isoformat(),
|
|
application=_build_application_info(),
|
|
health=HealthResponse(**health_data),
|
|
runtime=DebugRuntimeInfo(
|
|
connection_info=radio_runtime.connection_info,
|
|
connection_desired=radio_runtime.connection_desired,
|
|
setup_in_progress=radio_runtime.is_setup_in_progress,
|
|
setup_complete=radio_runtime.is_setup_complete,
|
|
channels_with_incoming_messages=channels_with_incoming_messages,
|
|
max_channels=radio_runtime.max_channels,
|
|
path_hash_mode=radio_runtime.path_hash_mode,
|
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
|
remediation_flags={
|
|
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
|
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
|
},
|
|
),
|
|
radio_probe=radio_probe,
|
|
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
|
)
|