More stable MC object reference and proper radio disconnection detection

This commit is contained in:
Jack Kingsman
2026-02-23 19:11:58 -08:00
parent cba9e20698
commit 152eab99db
14 changed files with 342 additions and 126 deletions
+10 -2
View File
@@ -2,13 +2,14 @@ import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.config import setup_logging
from app.database import db
from app.frontend_static import register_frontend_static_routes
from app.radio import radio_manager
from app.radio import RadioDisconnectedError, radio_manager
from app.radio_sync import (
stop_message_polling,
stop_periodic_advert,
@@ -75,6 +76,13 @@ app.add_middleware(
allow_headers=["*"],
)
@app.exception_handler(RadioDisconnectedError)
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
"""Return 503 when a radio disconnect race occurs during an operation."""
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
+21 -6
View File
@@ -20,6 +20,10 @@ class RadioOperationBusyError(RadioOperationError):
"""Raised when a non-blocking radio operation cannot acquire the lock."""
class RadioDisconnectedError(RadioOperationError):
"""Raised when the radio disconnects between pre-check and lock acquisition."""
@asynccontextmanager
async def _noop_context():
"""No-op async context manager for optional nesting."""
@@ -166,37 +170,48 @@ class RadioManager:
pause_polling: bool = False,
suspend_auto_fetch: bool = False,
blocking: bool = True,
meshcore: MeshCore | None = None,
):
"""Acquire shared radio lock and optionally pause polling / auto-fetch.
After acquiring the lock, resolves the current MeshCore instance and
yields it. Callers get a fresh reference via ``async with ... as mc:``,
avoiding stale-reference bugs when a reconnect swaps ``_meshcore``
between the pre-check and the lock acquisition.
Args:
name: Human-readable operation name for logs/errors.
pause_polling: Pause fallback message polling while held.
suspend_auto_fetch: Stop MeshCore auto message fetching while held.
blocking: If False, fail immediately when lock is held.
meshcore: Optional explicit MeshCore instance for auto-fetch control.
Raises:
RadioDisconnectedError: If the radio disconnected before the lock
was acquired (``_meshcore`` is ``None``).
"""
await self._acquire_operation_lock(name, blocking=blocking)
mc = self._meshcore
if mc is None:
self._release_operation_lock(name)
raise RadioDisconnectedError("Radio disconnected")
poll_context = _noop_context()
if pause_polling:
from app.radio_sync import pause_polling as pause_polling_context
poll_context = pause_polling_context()
mc = meshcore or self._meshcore
auto_fetch_paused = False
try:
async with poll_context:
if suspend_auto_fetch and mc is not None:
if suspend_auto_fetch:
await mc.stop_auto_message_fetching()
auto_fetch_paused = True
yield
yield mc
finally:
try:
if auto_fetch_paused and mc is not None:
if auto_fetch_paused:
try:
await mc.start_auto_message_fetching()
except Exception as e:
+1 -3
View File
@@ -573,13 +573,11 @@ async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
logger.debug("Cannot sync contacts to radio: not connected")
return {"loaded": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
try:
async with radio_manager.radio_operation(
"sync_recent_contacts_to_radio",
blocking=False,
):
) as mc:
_last_contact_sync = now
# Build prioritized contact list:
+2 -2
View File
@@ -85,12 +85,12 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
@router.post("/sync")
async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, le=40)) -> dict:
"""Sync channels from the radio to the database."""
mc = require_connected()
require_connected()
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
count = 0
async with radio_manager.radio_operation("sync_channels_from_radio"):
async with radio_manager.radio_operation("sync_channels_from_radio") as mc:
for idx in range(max_channels):
result = await mc.commands.get_channel(idx)
+14 -17
View File
@@ -264,11 +264,11 @@ async def get_contact(public_key: str) -> Contact:
@router.post("/sync")
async def sync_contacts_from_radio() -> dict:
"""Sync contacts from the radio to the database."""
mc = require_connected()
require_connected()
logger.info("Syncing contacts from radio")
async with radio_manager.radio_operation("sync_contacts_from_radio", meshcore=mc):
async with radio_manager.radio_operation("sync_contacts_from_radio") as mc:
result = await mc.commands.get_contacts()
if result.type == EventType.ERROR:
@@ -294,11 +294,11 @@ async def sync_contacts_from_radio() -> dict:
@router.post("/{public_key}/remove-from-radio")
async def remove_contact_from_radio(public_key: str) -> dict:
"""Remove a contact from the radio (keeps it in database)."""
mc = require_connected()
require_connected()
contact = await _resolve_contact_or_404(public_key)
async with radio_manager.radio_operation("remove_contact_from_radio", meshcore=mc):
async with radio_manager.radio_operation("remove_contact_from_radio") as mc:
# Get the contact from radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not radio_contact:
@@ -322,11 +322,11 @@ async def remove_contact_from_radio(public_key: str) -> dict:
@router.post("/{public_key}/add-to-radio")
async def add_contact_to_radio(public_key: str) -> dict:
"""Add a contact from the database to the radio."""
mc = require_connected()
require_connected()
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
async with radio_manager.radio_operation("add_contact_to_radio", meshcore=mc):
async with radio_manager.radio_operation("add_contact_to_radio") as mc:
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
@@ -361,9 +361,8 @@ async def delete_contact(public_key: str) -> dict:
contact = await _resolve_contact_or_404(public_key)
# Remove from radio if connected and contact is on radio
if radio_manager.is_connected and radio_manager.meshcore:
mc = radio_manager.meshcore
async with radio_manager.radio_operation("delete_contact_from_radio", meshcore=mc):
if radio_manager.is_connected:
async with radio_manager.radio_operation("delete_contact_from_radio") as mc:
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
logger.info(
@@ -385,7 +384,7 @@ async def request_telemetry(public_key: str, request: TelemetryRequest) -> Telem
The contact must be a repeater (type=2). If not on the radio, it will be added.
Uses login + status request with retry logic.
"""
mc = require_connected()
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
@@ -399,10 +398,9 @@ async def request_telemetry(public_key: str, request: TelemetryRequest) -> Telem
async with radio_manager.radio_operation(
"request_telemetry",
meshcore=mc,
pause_polling=True,
suspend_auto_fetch=True,
):
) as mc:
# Prepare connection (add/remove dance + login)
await prepare_repeater_connection(mc, contact, request.password)
@@ -552,7 +550,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
- reboot
- ver
"""
mc = require_connected()
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
@@ -566,10 +564,9 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
async with radio_manager.radio_operation(
"send_repeater_command",
meshcore=mc,
pause_polling=True,
suspend_auto_fetch=True,
):
) as mc:
# Add contact to radio with path from DB
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await mc.commands.add_contact(contact.to_radio_dict())
@@ -621,7 +618,7 @@ async def request_trace(public_key: str) -> TraceResponse:
(no intermediate repeaters). The radio firmware requires at least one
node in the path.
"""
mc = require_connected()
require_connected()
contact = await _resolve_contact_or_404(public_key)
@@ -631,7 +628,7 @@ async def request_trace(public_key: str) -> TraceResponse:
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
# from the reader loop, not via get_msg().
async with radio_manager.radio_operation("request_trace", pause_polling=True):
async with radio_manager.radio_operation("request_trace", pause_polling=True) as mc:
# Ensure contact is on radio so the trace can reach them
await mc.commands.add_contact(contact.to_radio_dict())
+16 -14
View File
@@ -43,7 +43,7 @@ async def list_messages(
@router.post("/direct", response_model=Message)
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
"""Send a direct message to a contact."""
mc = require_connected()
require_connected()
# First check our database for the contact
from app.repository import ContactRepository
@@ -69,7 +69,7 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
# so we can't rely on it to know if the firmware has the contact.
# add_contact is idempotent - updates if exists, adds if not.
contact_data = db_contact.to_radio_dict()
async with radio_manager.radio_operation("send_direct_message"):
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
@@ -163,7 +163,7 @@ TEMP_RADIO_SLOT = 0
@router.post("/channel", response_model=Message)
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
"""Send a message to a channel."""
mc = require_connected()
require_connected()
# Get channel info from our database
from app.decoder import calculate_channel_hash
@@ -192,12 +192,14 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
expected_hash,
)
channel_key_upper = request.channel_key.upper()
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
message_id: int | None = None
now: int | None = None
radio_name: str = ""
text_with_sender: str = request.text
async with radio_manager.radio_operation("send_channel_message"):
async with radio_manager.radio_operation("send_channel_message") as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
@@ -318,7 +320,7 @@ async def resend_channel_message(
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
new packet. Creates a new message row in the database. No time window restriction.
"""
mc = require_connected()
require_connected()
from app.repository import ChannelRepository
@@ -352,12 +354,6 @@ async def resend_channel_message(
else:
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
try:
key_bytes = bytes.fromhex(msg.conversation_key)
except ValueError:
@@ -365,7 +361,13 @@ async def resend_channel_message(
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
) from None
async with radio_manager.radio_operation("resend_channel_message"):
async with radio_manager.radio_operation("resend_channel_message") as mc:
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
+7 -10
View File
@@ -70,9 +70,9 @@ async def get_radio_config() -> RadioConfigResponse:
@router.patch("/config", response_model=RadioConfigResponse)
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
"""Update radio configuration. Only provided fields will be updated."""
mc = require_connected()
require_connected()
async with radio_manager.radio_operation("update_radio_config"):
async with radio_manager.radio_operation("update_radio_config") as mc:
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
@@ -117,7 +117,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
@router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only."""
mc = require_connected()
require_connected()
try:
key_bytes = bytes.fromhex(update.private_key)
@@ -125,7 +125,7 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
raise HTTPException(status_code=400, detail="Invalid hex string for private key") from None
logger.info("Importing private key")
async with radio_manager.radio_operation("import_private_key", meshcore=mc):
async with radio_manager.radio_operation("import_private_key") as mc:
result = await mc.commands.import_private_key(key_bytes)
if result.type == EventType.ERROR:
@@ -167,13 +167,10 @@ async def reboot_radio() -> dict:
If not connected: attempts to reconnect (same as /reconnect endpoint).
"""
# If connected, send reboot command
if radio_manager.is_connected and radio_manager.meshcore:
if radio_manager.is_connected:
logger.info("Rebooting radio")
async with radio_manager.radio_operation(
"reboot_radio",
meshcore=radio_manager.meshcore,
):
await radio_manager.meshcore.commands.reboot()
async with radio_manager.radio_operation("reboot_radio") as mc:
await mc.commands.reboot()
return {
"status": "ok",
"message": "Reboot command sent. Radio will reconnect automatically.",