mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 18:23:20 +02:00
More stable MC object reference and proper radio disconnection detection
This commit is contained in:
+10
-2
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user