Add ability to pause radio connection (closes #51)

This commit is contained in:
Jack Kingsman
2026-03-11 17:17:03 -07:00
parent e993009782
commit 4e0b6a49b0
18 changed files with 371 additions and 34 deletions
+24
View File
@@ -121,6 +121,7 @@ class RadioManager:
def __init__(self):
self._meshcore: MeshCore | None = None
self._connection_info: str | None = None
self._connection_desired: bool = True
self._reconnect_task: asyncio.Task | None = None
self._last_connected: bool = False
self._reconnect_lock: asyncio.Lock | None = None
@@ -246,6 +247,20 @@ class RadioManager:
def is_setup_complete(self) -> bool:
return self._setup_complete
@property
def connection_desired(self) -> bool:
return self._connection_desired
def resume_connection(self) -> None:
"""Allow connection monitor and manual reconnects to establish transport again."""
self._connection_desired = True
async def pause_connection(self) -> None:
"""Stop automatic reconnect attempts and tear down any current transport."""
self._connection_desired = False
self._last_connected = False
await self.disconnect()
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
@@ -344,6 +359,10 @@ class RadioManager:
self._reconnect_lock = asyncio.Lock()
async with self._reconnect_lock:
if not self._connection_desired:
logger.info("Reconnect skipped because connection is paused by operator")
return False
# If we became connected while waiting for the lock (another
# reconnect succeeded ahead of us), skip the redundant attempt.
if self.is_connected:
@@ -364,6 +383,11 @@ class RadioManager:
# Try to connect (will auto-detect if no port specified)
await self.connect()
if not self._connection_desired:
logger.info("Reconnect completed after pause request; disconnecting transport")
await self.disconnect()
return False
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
if broadcast_on_success:
+20
View File
@@ -15,6 +15,7 @@ class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
@@ -56,12 +57,31 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
if not radio_connected:
setup_complete = False
connection_desired = getattr(radio_manager, "connection_desired", True)
if not isinstance(connection_desired, bool):
connection_desired = True
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
if not isinstance(is_reconnecting, bool):
is_reconnecting = False
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
if not connection_desired:
radio_state = "paused"
elif radio_initializing:
radio_state = "initializing"
elif radio_connected:
radio_state = "connected"
elif is_reconnecting:
radio_state = "connecting"
else:
radio_state = "disconnected"
return {
"status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected,
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
+23 -3
View File
@@ -14,13 +14,14 @@ from app.services.radio_commands import (
import_private_key_and_refresh_keystore,
)
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
async def _prepare_connected(*, broadcast_on_success: bool) -> None:
await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
@@ -170,6 +171,8 @@ async def send_advertisement() -> dict:
async def _attempt_reconnect() -> dict:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()
if radio_manager.is_reconnecting:
return {
"status": "pending",
@@ -194,6 +197,20 @@ async def _attempt_reconnect() -> dict:
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
@router.post("/disconnect")
async def disconnect_radio() -> dict:
"""Disconnect from the radio and pause automatic reconnect attempts."""
logger.info("Manual radio disconnect requested")
await radio_manager.pause_connection()
broadcast_health(False, radio_manager.connection_info)
return {
"status": "ok",
"message": "Disconnected. Automatic reconnect is paused.",
"connected": False,
"paused": True,
}
@router.post("/reboot")
async def reboot_radio() -> dict:
"""Reboot the radio, or reconnect if not currently connected.
@@ -228,8 +245,11 @@ async def reconnect_radio() -> dict:
logger.info("Radio connected but setup incomplete, retrying setup")
try:
await _prepare_connected(broadcast_on_success=True)
if not await _prepare_connected(broadcast_on_success=True):
raise HTTPException(status_code=503, detail="Radio connection is paused")
return {"status": "ok", "message": "Setup completed", "connected": True}
except HTTPException:
raise
except Exception as e:
logger.exception("Post-connect setup failed")
raise HTTPException(
+21 -3
View File
@@ -147,10 +147,15 @@ async def run_post_connect_setup(radio_manager) -> None:
logger.info("Post-connect setup complete")
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> None:
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> bool:
"""Finish setup for an already-connected radio and optionally broadcast health."""
from app.websocket import broadcast_error, broadcast_health
if not radio_manager.connection_desired:
if radio_manager.is_connected:
await radio_manager.disconnect()
return False
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
try:
await radio_manager.post_connect_setup()
@@ -177,9 +182,15 @@ async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool =
)
raise RuntimeError("Post-connect setup timed out") from exc
if not radio_manager.connection_desired:
if radio_manager.is_connected:
await radio_manager.disconnect()
return False
radio_manager._last_connected = True
if broadcast_on_success:
broadcast_health(True, radio_manager.connection_info)
return True
async def reconnect_and_prepare_radio(
@@ -192,8 +203,7 @@ async def reconnect_and_prepare_radio(
if not connected:
return False
await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
return True
return await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
async def connection_monitor_loop(radio_manager) -> None:
@@ -209,6 +219,7 @@ async def connection_monitor_loop(radio_manager) -> None:
await asyncio.sleep(check_interval_seconds)
current_connected = radio_manager.is_connected
connection_desired = radio_manager.connection_desired
if radio_manager._last_connected and not current_connected:
logger.warning("Radio connection lost, broadcasting status change")
@@ -216,6 +227,13 @@ async def connection_monitor_loop(radio_manager) -> None:
radio_manager._last_connected = False
consecutive_setup_failures = 0
if not connection_desired:
if current_connected:
logger.info("Radio connection paused by operator; disconnecting transport")
await radio_manager.disconnect()
consecutive_setup_failures = 0
continue
if not current_connected:
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
radio_manager,
+4 -2
View File
@@ -74,10 +74,12 @@ class RadioRuntime:
async def disconnect(self) -> None:
await self.manager.disconnect()
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None:
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> bool:
from app.services.radio_lifecycle import prepare_connected_radio
await prepare_connected_radio(self.manager, broadcast_on_success=broadcast_on_success)
return await prepare_connected_radio(
self.manager, broadcast_on_success=broadcast_on_success
)
async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool:
from app.services.radio_lifecycle import reconnect_and_prepare_radio