From 2710cafb2198a714ddc98157a244049bde98e527 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Thu, 12 Mar 2026 19:47:21 -0700
Subject: [PATCH] Add health endpoint
---
AGENTS.md | 1 +
app/AGENTS.md | 4 +
app/config.py | 56 ++++
app/event_handlers.py | 2 +-
app/main.py | 2 +
app/radio_sync.py | 49 +--
app/repository/channels.py | 24 ++
app/repository/contacts.py | 8 -
app/routers/contacts.py | 4 +-
app/routers/debug.py | 298 ++++++++++++++++++
app/services/message_send.py | 8 -
frontend/src/components/ContactInfoPane.tsx | 5 -
.../settings/SettingsAboutSection.tsx | 13 +
.../src/test/settingsAboutSection.test.tsx | 23 ++
tests/test_api.py | 120 +++++++
tests/test_event_handlers.py | 2 +-
tests/test_radio_sync.py | 58 ++--
tests/test_send_messages.py | 3 -
18 files changed, 607 insertions(+), 73 deletions(-)
create mode 100644 app/routers/debug.py
create mode 100644 frontend/src/test/settingsAboutSection.test.tsx
diff --git a/AGENTS.md b/AGENTS.md
index bb5ce46..d15e642 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -290,6 +290,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
+| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
diff --git a/app/AGENTS.md b/app/AGENTS.md
index 183fa07..3b7b4e6 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -53,6 +53,7 @@ app/
├── frontend_static.py # Mount/serve built frontend (production)
└── routers/
├── health.py
+ ├── debug.py
├── radio.py
├── contacts.py
├── channels.py
@@ -149,6 +150,9 @@ app/
### Health
- `GET /health`
+### Debug
+- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
+
### Radio
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
diff --git a/app/config.py b/app/config.py
index 6525389..8d293c4 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,5 +1,7 @@
import logging
import logging.config
+from collections import deque
+from threading import Lock
from typing import Literal
from pydantic import model_validator
@@ -67,6 +69,47 @@ class Settings(BaseSettings):
settings = Settings()
+class _RingBufferLogHandler(logging.Handler):
+ """Keep a bounded in-memory tail of formatted log lines."""
+
+ def __init__(self, max_lines: int = 1000) -> None:
+ super().__init__()
+ self._buffer: deque[str] = deque(maxlen=max_lines)
+ self._lock = Lock()
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ line = self.format(record)
+ except Exception:
+ self.handleError(record)
+ return
+ with self._lock:
+ self._buffer.append(line)
+
+ def get_lines(self, limit: int = 1000) -> list[str]:
+ with self._lock:
+ if limit <= 0:
+ return []
+ return list(self._buffer)[-limit:]
+
+ def clear(self) -> None:
+ with self._lock:
+ self._buffer.clear()
+
+
+_recent_log_handler = _RingBufferLogHandler(max_lines=1000)
+
+
+def get_recent_log_lines(limit: int = 1000) -> list[str]:
+ """Return recent formatted log lines from the in-memory ring buffer."""
+ return _recent_log_handler.get_lines(limit)
+
+
+def clear_recent_log_lines() -> None:
+ """Clear the in-memory log ring buffer."""
+ _recent_log_handler.clear()
+
+
class _RepeatSquelch(logging.Filter):
"""Suppress rapid-fire identical messages and emit a summary instead.
@@ -152,6 +195,19 @@ def setup_logging() -> None:
},
}
)
+
+ _recent_log_handler.setLevel(logging.DEBUG)
+ _recent_log_handler.setFormatter(
+ logging.Formatter(
+ fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+ )
+ for logger_name in ("", "uvicorn", "uvicorn.error", "uvicorn.access"):
+ target = logging.getLogger(logger_name)
+ if _recent_log_handler not in target.handlers:
+ target.addHandler(_recent_log_handler)
+
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
diff --git a/app/event_handlers.py b/app/event_handlers.py
index 83deb53..617b84e 100644
--- a/app/event_handlers.py
+++ b/app/event_handlers.py
@@ -243,7 +243,7 @@ async def on_new_contact(event: "Event") -> None:
logger.debug("New contact: %s", public_key[:12])
- contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True)
+ contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
contact_upsert.last_seen = int(time.time())
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
diff --git a/app/main.py b/app/main.py
index af09649..35d499c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -21,6 +21,7 @@ from app.radio_sync import (
from app.routers import (
channels,
contacts,
+ debug,
fanout,
health,
messages,
@@ -136,6 +137,7 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
+app.include_router(debug.router, prefix="/api")
app.include_router(fanout.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
diff --git a/app/radio_sync.py b/app/radio_sync.py
index b2cee5f..d8fbdba 100644
--- a/app/radio_sync.py
+++ b/app/radio_sync.py
@@ -50,7 +50,6 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
"last_advert": contact.last_advert,
"lat": contact.lat,
"lon": contact.lon,
- "on_radio": contact.on_radio,
}
@@ -380,16 +379,19 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
"""Sync and offload both contacts and channels, then ensure defaults exist."""
logger.info("Starting full radio sync and offload")
+ # Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
+ # cycle so old rows stop claiming radio residency we do not actively track.
+ await ContactRepository.clear_on_radio_except([])
+
contacts_result = await sync_and_offload_contacts(mc)
channels_result = await sync_and_offload_channels(mc)
# Ensure default channels exist
await ensure_default_channels()
- # Reload favorites plus a working-set fill back onto the radio immediately
- # so they do not stay in on_radio=False limbo after offload. Pass mc directly
- # since the caller already holds the radio operation lock (asyncio.Lock is not
- # reentrant).
+ # Reload favorites plus a working-set fill back onto the radio immediately.
+ # Pass mc directly since the caller already holds the radio operation lock
+ # (asyncio.Lock is not reentrant).
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
return {
@@ -776,20 +778,8 @@ _last_contact_sync: float = 0.0
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
-async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
- """
- Core logic for loading contacts onto the radio.
-
- Fill order is:
- 1. Favorite contacts
- 2. Most recently interacted-with non-repeaters
- 3. Most recently advert-heard non-repeaters without interaction history
-
- Favorite contacts are always reloaded first, up to the configured capacity.
- Additional non-favorite fill stops at the refill target (80% of capacity).
-
- Caller must hold the radio operation lock and pass a valid MeshCore instance.
- """
+async def get_contacts_selected_for_radio_sync() -> list[Contact]:
+ """Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get()
max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
@@ -856,6 +846,24 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
refill_target,
max_contacts,
)
+ return selected_contacts
+
+
+async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
+ """
+ Core logic for loading contacts onto the radio.
+
+ Fill order is:
+ 1. Favorite contacts
+ 2. Most recently interacted-with non-repeaters
+ 3. Most recently advert-heard non-repeaters without interaction history
+
+ Favorite contacts are always reloaded first, up to the configured capacity.
+ Additional non-favorite fill stops at the refill target (80% of capacity).
+
+ Caller must hold the radio operation lock and pass a valid MeshCore instance.
+ """
+ selected_contacts = await get_contacts_selected_for_radio_sync()
return await _load_contacts_to_radio(mc, selected_contacts)
@@ -928,8 +936,6 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
already_on_radio += 1
- if not contact.on_radio:
- await ContactRepository.set_on_radio(contact.public_key, True)
continue
try:
@@ -937,7 +943,6 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
result = await mc.commands.add_contact(radio_contact_payload)
if result.type == EventType.OK:
loaded += 1
- await ContactRepository.set_on_radio(contact.public_key, True)
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
else:
failed += 1
diff --git a/app/repository/channels.py b/app/repository/channels.py
index f35a6dd..8a28ade 100644
--- a/app/repository/channels.py
+++ b/app/repository/channels.py
@@ -66,6 +66,30 @@ class ChannelRepository:
for row in rows
]
+ @staticmethod
+ async def get_on_radio() -> list[Channel]:
+ """Return channels currently marked as resident on the radio in the database."""
+ cursor = await db.conn.execute(
+ """
+ SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
+ FROM channels
+ WHERE on_radio = 1
+ ORDER BY name
+ """
+ )
+ rows = await cursor.fetchall()
+ return [
+ Channel(
+ key=row["key"],
+ name=row["name"],
+ is_hashtag=bool(row["is_hashtag"]),
+ on_radio=bool(row["on_radio"]),
+ flood_scope_override=row["flood_scope_override"],
+ last_read_at=row["last_read_at"],
+ )
+ for row in rows
+ ]
+
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
diff --git a/app/repository/contacts.py b/app/repository/contacts.py
index b5e3ece..1f40e84 100644
--- a/app/repository/contacts.py
+++ b/app/repository/contacts.py
@@ -352,14 +352,6 @@ class ContactRepository:
)
await db.conn.commit()
- @staticmethod
- async def set_on_radio(public_key: str, on_radio: bool) -> None:
- await db.conn.execute(
- "UPDATE contacts SET on_radio = ? WHERE public_key = ?",
- (on_radio, public_key.lower()),
- )
- await db.conn.commit()
-
@staticmethod
async def clear_on_radio_except(keep_keys: list[str]) -> None:
"""Set on_radio=False for all contacts NOT in keep_keys."""
diff --git a/app/routers/contacts.py b/app/routers/contacts.py
index 5da0a0c..bd56f5b 100644
--- a/app/routers/contacts.py
+++ b/app/routers/contacts.py
@@ -66,8 +66,8 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
- """Push the current effective route to the radio when the contact is already loaded."""
- if not radio_manager.is_connected or not contact.on_radio:
+ """Best-effort push the current effective route to the radio when connected."""
+ if not radio_manager.is_connected:
return
try:
diff --git a/app/routers/debug.py b/app/routers/debug.py
new file mode 100644
index 0000000..77f3c1e
--- /dev/null
+++ b/app/routers/debug.py
@@ -0,0 +1,298 @@
+import hashlib
+import importlib.metadata
+import logging
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+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.routers.health import HealthResponse, build_health_data
+from app.services.radio_runtime import radio_runtime
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(tags=["debug"])
+
+
+class DebugApplicationInfo(BaseModel):
+ version: str
+ commit_hash: 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
+ max_channels: int
+ path_hash_mode: int
+ path_hash_mode_supported: bool
+ channel_slot_reuse_enabled: bool
+ channel_send_cache_capacity: int
+ channel_send_cache: list[dict[str, int | str]]
+ 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 _repo_root() -> Path:
+ return Path(__file__).resolve().parents[2]
+
+
+def _get_app_version() -> str:
+ try:
+ return importlib.metadata.version("remoteterm-meshcore")
+ except Exception:
+ pyproject = _repo_root() / "pyproject.toml"
+ try:
+ for line in pyproject.read_text().splitlines():
+ if line.startswith("version = "):
+ return line.split('"')[1]
+ except Exception:
+ pass
+ return "0.0.0"
+
+
+def _git_output(*args: str) -> str | None:
+ try:
+ result = subprocess.run(
+ ["git", *args],
+ cwd=_repo_root(),
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except Exception:
+ return None
+ output = result.stdout.strip()
+ return output or None
+
+
+def _build_application_info() -> DebugApplicationInfo:
+ dirty_output = _git_output("status", "--porcelain")
+ return DebugApplicationInfo(
+ version=_get_app_version(),
+ commit_hash=_git_output("rev-parse", "HEAD"),
+ 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()
+ 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,
+ 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(),
+ channel_send_cache=[
+ {"channel_key": channel_key, "slot": slot}
+ for channel_key, slot in radio_runtime.get_channel_send_cache_snapshot()
+ ],
+ 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=get_recent_log_lines(limit=1000),
+ )
diff --git a/app/services/message_send.py b/app/services/message_send.py
index 5e90b23..ff69ee6 100644
--- a/app/services/message_send.py
+++ b/app/services/message_send.py
@@ -168,20 +168,15 @@ async def send_direct_message_to_contact(
) -> Any:
"""Send a direct message and persist/broadcast the outgoing row."""
contact_data = contact.to_radio_dict()
- contact_ensured_on_radio = False
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
- else:
- contact_ensured_on_radio = True
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
- else:
- contact_ensured_on_radio = True
logger.info("Sending direct message to %s", contact.public_key[:12])
now = int(now_fn())
@@ -194,9 +189,6 @@ async def send_direct_message_to_contact(
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
- if contact_ensured_on_radio and not contact.on_radio:
- await contact_repository.set_on_radio(contact.public_key.lower(), True)
-
message = await create_outgoing_direct_message(
conversation_key=contact.public_key.lower(),
text=text,
diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx
index 09e6bda..3e05475 100644
--- a/frontend/src/components/ContactInfoPane.tsx
+++ b/frontend/src/components/ContactInfoPane.tsx
@@ -278,11 +278,6 @@ export function ContactInfoPane({
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
- {contact.on_radio && (
-
- On Radio
-
- )}
diff --git a/frontend/src/components/settings/SettingsAboutSection.tsx b/frontend/src/components/settings/SettingsAboutSection.tsx
index e79613d..23efcaa 100644
--- a/frontend/src/components/settings/SettingsAboutSection.tsx
+++ b/frontend/src/components/settings/SettingsAboutSection.tsx
@@ -113,6 +113,19 @@ export function SettingsAboutSection({ className }: { className?: string }) {
+
+
+
+
);
diff --git a/frontend/src/test/settingsAboutSection.test.tsx b/frontend/src/test/settingsAboutSection.test.tsx
new file mode 100644
index 0000000..18a0aba
--- /dev/null
+++ b/frontend/src/test/settingsAboutSection.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsAboutSection } from '../components/settings/SettingsAboutSection';
+
+describe('SettingsAboutSection', () => {
+ beforeEach(() => {
+ vi.stubGlobal('__APP_VERSION__', '3.2.0-test');
+ vi.stubGlobal('__COMMIT_HASH__', 'deadbeef');
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('renders the debug support snapshot link', () => {
+ render();
+
+ const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
+ expect(link).toHaveAttribute('href', '/api/debug');
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/tests/test_api.py b/tests/test_api.py
index 5b395cf..ab9b452 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -5,6 +5,7 @@ Uses httpx.AsyncClient or direct function calls with real in-memory SQLite.
"""
import hashlib
+import logging
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -109,6 +110,125 @@ class TestHealthEndpoint:
assert data["connection_info"] is None
+class TestDebugEndpoint:
+ """Test the debug support snapshot endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_support_snapshot_returns_logs_and_live_radio_audits(self, test_db, client):
+ """Debug snapshot should include recent logs plus live radio/contact/channel state."""
+ from meshcore import EventType
+
+ from app.config import clear_recent_log_lines
+ from app.routers.debug import DebugApplicationInfo
+
+ clear_recent_log_lines()
+
+ contact_key = "ab" * 32
+ channel_key = "CD" * 16
+ await _insert_contact(contact_key, "Alice", last_contacted=1700000000)
+ await ChannelRepository.upsert(key=channel_key, name="#flightless", on_radio=False)
+
+ radio_manager.max_channels = 2
+ radio_manager.path_hash_mode = 1
+ radio_manager.path_hash_mode_supported = True
+ radio_manager._connection_info = "Serial: /dev/ttyUSB0"
+ radio_manager.note_channel_slot_loaded(channel_key, 0)
+
+ mock_mc = MagicMock()
+ mock_mc.self_info = {"name": "TestNode", "radio_freq": 910.525}
+ mock_mc.stop_auto_message_fetching = AsyncMock()
+ mock_mc.start_auto_message_fetching = AsyncMock()
+ mock_mc.commands.send_device_query = AsyncMock(
+ return_value=MagicMock(type=EventType.DEVICE_INFO, payload={"max_channels": 2})
+ )
+ mock_mc.commands.get_stats_core = AsyncMock(
+ return_value=MagicMock(type=EventType.STATS_CORE, payload={"heap_free": 1234})
+ )
+ mock_mc.commands.get_stats_radio = AsyncMock(
+ return_value=MagicMock(type=EventType.STATS_RADIO, payload={"tx_good": 9})
+ )
+ mock_mc.commands.get_contacts = AsyncMock(
+ return_value=MagicMock(
+ type=EventType.OK,
+ payload={contact_key: {"adv_name": "Alice"}},
+ )
+ )
+
+ def _channel_event(slot: int) -> MagicMock:
+ if slot == 0:
+ return MagicMock(
+ type=EventType.CHANNEL_INFO,
+ payload={
+ "channel_name": "#flightless",
+ "channel_secret": bytes.fromhex(channel_key),
+ },
+ )
+ return MagicMock(type=EventType.OK, payload={})
+
+ mock_mc.commands.get_channel = AsyncMock(side_effect=_channel_event)
+ radio_manager._meshcore = mock_mc
+
+ logging.getLogger("tests.debug").warning("support snapshot marker")
+
+ with patch(
+ "app.routers.debug._build_application_info",
+ return_value=DebugApplicationInfo(
+ version="3.2.0",
+ commit_hash="deadbeef",
+ git_branch="main",
+ git_dirty=False,
+ python_version="3.12.0",
+ ),
+ ):
+ response = await client.get("/api/debug")
+
+ assert response.status_code == 200
+ payload = response.json()
+
+ assert payload["application"]["commit_hash"] == "deadbeef"
+ assert payload["runtime"]["channel_slot_reuse_enabled"] is True
+ assert payload["runtime"]["channel_send_cache"] == [{"channel_key": channel_key, "slot": 0}]
+ assert any("support snapshot marker" in line for line in payload["logs"])
+
+ radio_probe = payload["radio_probe"]
+ assert radio_probe["performed"] is True
+ assert radio_probe["device_info"] == {"max_channels": 2}
+ assert radio_probe["stats_core"] == {"heap_free": 1234}
+ assert radio_probe["stats_radio"] == {"tx_good": 9}
+ assert radio_probe["contacts"]["expected_and_found"] == 1
+ assert radio_probe["contacts"]["expected_but_not_found"] == []
+ assert radio_probe["contacts"]["found_but_not_expected"] == []
+ assert radio_probe["channels"]["matched_slots"] == 2
+ assert radio_probe["channels"]["wrong_slots"] == []
+
+ @pytest.mark.asyncio
+ async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
+ """Debug snapshot should still return logs and runtime state when radio is disconnected."""
+ from app.config import clear_recent_log_lines
+ from app.routers.debug import DebugApplicationInfo
+
+ clear_recent_log_lines()
+ radio_manager._meshcore = None
+ radio_manager._connection_info = None
+
+ with patch(
+ "app.routers.debug._build_application_info",
+ return_value=DebugApplicationInfo(
+ version="3.2.0",
+ commit_hash="deadbeef",
+ git_branch="main",
+ git_dirty=False,
+ python_version="3.12.0",
+ ),
+ ):
+ response = await client.get("/api/debug")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["radio_probe"]["performed"] is False
+ assert payload["radio_probe"]["errors"] == ["Radio not connected"]
+
+
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py
index d9ad184..9b452b0 100644
--- a/tests/test_event_handlers.py
+++ b/tests/test_event_handlers.py
@@ -915,7 +915,7 @@ class TestOnNewContact:
contact = await ContactRepository.get_by_key("cc" * 32)
assert contact is not None
assert contact.name == "Charlie"
- assert contact.on_radio is True
+ assert contact.on_radio is False
assert contact.last_seen == 1700000000
mock_broadcast.assert_called_once()
diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py
index c499ab3..d50e4aa 100644
--- a/tests/test_radio_sync.py
+++ b/tests/test_radio_sync.py
@@ -20,6 +20,7 @@ from app.radio_sync import (
ensure_contact_on_radio,
is_polling_paused,
pause_polling,
+ sync_and_offload_all,
sync_radio_time,
sync_recent_contacts_to_radio,
)
@@ -211,11 +212,6 @@ class TestSyncRecentContactsToRadio:
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 2
- # Verify contacts are now marked as on_radio in DB
- alice = await ContactRepository.get_by_key(KEY_A)
- bob = await ContactRepository.get_by_key(KEY_B)
- assert alice.on_radio is True
- assert bob.on_radio is True
@pytest.mark.asyncio
async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db):
@@ -272,6 +268,39 @@ class TestSyncRecentContactsToRadio:
]
assert loaded_keys == favorite_keys
+
+class TestSyncAndOffloadAll:
+ """Test session-local contact radio residency reset behavior."""
+
+ @pytest.mark.asyncio
+ async def test_clears_stale_contact_on_radio_flags_before_reload(self, test_db):
+ await _insert_contact(KEY_A, "Alice", on_radio=True)
+ await _insert_contact(KEY_B, "Bob", on_radio=True)
+
+ mock_mc = MagicMock()
+
+ with (
+ patch(
+ "app.radio_sync.sync_and_offload_contacts",
+ new=AsyncMock(return_value={"synced": 0, "removed": 0}),
+ ),
+ patch(
+ "app.radio_sync.sync_and_offload_channels",
+ new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
+ ),
+ patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
+ patch(
+ "app.radio_sync.sync_recent_contacts_to_radio",
+ new=AsyncMock(return_value={"loaded": 0, "already_on_radio": 0, "failed": 0}),
+ ),
+ ):
+ await sync_and_offload_all(mock_mc)
+
+ alice = await ContactRepository.get_by_key(KEY_A)
+ bob = await ContactRepository.get_by_key(KEY_B)
+ assert alice is not None and alice.on_radio is False
+ assert bob is not None and bob.on_radio is False
+
@pytest.mark.asyncio
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
@@ -325,7 +354,7 @@ class TestSyncRecentContactsToRadio:
@pytest.mark.asyncio
async def test_skips_contacts_already_on_radio(self, test_db):
"""Contacts already on radio are counted but not re-added."""
- await _insert_contact(KEY_A, "Alice", on_radio=True)
+ await _insert_contact(KEY_A, "Alice", on_radio=False)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
@@ -382,23 +411,6 @@ class TestSyncRecentContactsToRadio:
assert result["loaded"] == 0
assert "error" in result
- @pytest.mark.asyncio
- async def test_marks_on_radio_when_found_but_not_flagged(self, test_db):
- """Contact found on radio but not flagged gets set_on_radio(True)."""
- await _insert_contact(KEY_A, "Alice", on_radio=False)
- await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
-
- mock_mc = MagicMock()
- mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
-
- radio_manager._meshcore = mock_mc
- result = await sync_recent_contacts_to_radio()
-
- assert result["already_on_radio"] == 1
- # Should update the flag since contact.on_radio was False
- contact = await ContactRepository.get_by_key(KEY_A)
- assert contact.on_radio is True
-
@pytest.mark.asyncio
async def test_handles_add_failure(self, test_db):
"""Failed add_contact increments the failed counter."""
diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py
index 2cd11d2..7042c6a 100644
--- a/tests/test_send_messages.py
+++ b/tests/test_send_messages.py
@@ -161,9 +161,6 @@ class TestOutgoingDMBroadcast:
assert contact_payload["out_path"] == "aa00bb00"
assert contact_payload["out_path_len"] == 2
assert contact_payload["out_path_hash_mode"] == 1
- contact = await ContactRepository.get_by_key(pub_key)
- assert contact is not None
- assert contact.on_radio is True
@pytest.mark.asyncio
async def test_send_dm_prefers_route_override_over_learned_path(self, test_db):