From 9388e1f50669c6f2f763fd273ed0245f2bf31be9 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:11:57 -0700 Subject: [PATCH] route startup and fanout through radio runtime --- app/AGENTS.md | 2 +- app/fanout/community_mqtt.py | 12 +++++++----- app/fanout/mqtt_base.py | 2 +- app/fanout/mqtt_community.py | 2 +- app/main.py | 10 +++------- app/services/radio_runtime.py | 9 +++++++++ tests/test_radio_runtime_service.py | 18 ++++++++++++++++++ 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/AGENTS.md b/app/AGENTS.md index 435ea22..d681ef9 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -77,7 +77,7 @@ app/ - `RadioManager.start_connection_monitor()` checks health every 5s. - `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`. -- Routers and shared dependencies should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. +- Routers, startup/lifespan code, and fanout helpers should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. - Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state. - Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks. diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index 67f901f..b4ba38f 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -244,7 +244,7 @@ def _build_radio_info() -> str: Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw values). Falls back to ``"0,0,0,0"`` when unavailable. """ - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager try: if radio_manager.meshcore and radio_manager.meshcore.self_info: @@ -329,7 +329,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): def _build_client_kwargs(self, settings: object) -> dict[str, Any]: s: CommunityMqttSettings = settings # type: ignore[assignment] from app.keystore import get_private_key, get_public_key - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager private_key = get_private_key() public_key = get_public_key() @@ -401,7 +401,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): if self._cached_device_info is not None: return self._cached_device_info - from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager + from app.radio import RadioDisconnectedError, RadioOperationBusyError + from app.services.radio_runtime import radio_runtime as radio_manager fallback = {"model": "unknown", "firmware_version": "unknown"} try: @@ -448,7 +449,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): ) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None: return self._cached_stats - from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager + from app.radio import RadioDisconnectedError, RadioOperationBusyError + from app.services.radio_runtime import radio_runtime as radio_manager try: async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc: @@ -489,7 +491,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): ) -> None: """Build and publish the enriched retained status message.""" from app.keystore import get_public_key - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager public_key = get_public_key() if public_key is None: diff --git a/app/fanout/mqtt_base.py b/app/fanout/mqtt_base.py index 9588839..db8ea6f 100644 --- a/app/fanout/mqtt_base.py +++ b/app/fanout/mqtt_base.py @@ -25,7 +25,7 @@ _BACKOFF_MIN = 5 def _broadcast_health() -> None: """Push updated health (including MQTT status) to all WS clients.""" - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_health broadcast_health(radio_manager.is_connected, radio_manager.connection_info) diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py index da6a0a8..0fea530 100644 --- a/app/fanout/mqtt_community.py +++ b/app/fanout/mqtt_community.py @@ -109,7 +109,7 @@ async def _publish_community_packet( """Format and publish a raw packet to the community broker.""" try: from app.keystore import get_public_key - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager public_key = get_public_key() if public_key is None: diff --git a/app/main.py b/app/main.py index e174f0c..55f7e80 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse from app.config import setup_logging from app.database import db from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes -from app.radio import RadioDisconnectedError, radio_manager +from app.radio import RadioDisconnectedError from app.radio_sync import ( stop_message_polling, stop_periodic_advert, @@ -30,6 +30,7 @@ from app.routers import ( statistics, ws, ) +from app.services.radio_runtime import radio_runtime as radio_manager setup_logging() logger = logging.getLogger(__name__) @@ -37,13 +38,8 @@ logger = logging.getLogger(__name__) async def _startup_radio_connect_and_setup() -> None: """Connect/setup the radio in the background so HTTP serving can start immediately.""" - from app.services.radio_lifecycle import reconnect_and_prepare_radio - try: - connected = await reconnect_and_prepare_radio( - radio_manager, - broadcast_on_success=True, - ) + connected = await radio_manager.reconnect_and_prepare(broadcast_on_success=True) if connected: logger.info("Connected to radio") else: diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index 7061753..eb10251 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -83,6 +83,15 @@ class RadioRuntime: async with self.manager.radio_operation(name, **kwargs) as mc: yield mc + async def start_connection_monitor(self) -> None: + await self.manager.start_connection_monitor() + + async def stop_connection_monitor(self) -> None: + await self.manager.stop_connection_monitor() + + async def disconnect(self) -> None: + await self.manager.disconnect() + async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None: from app.services.radio_lifecycle import prepare_connected_radio diff --git a/tests/test_radio_runtime_service.py b/tests/test_radio_runtime_service.py index aa53082..8af1c84 100644 --- a/tests/test_radio_runtime_service.py +++ b/tests/test_radio_runtime_service.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +from unittest.mock import AsyncMock import pytest from fastapi import HTTPException @@ -73,3 +74,20 @@ async def test_radio_operation_delegates_to_current_manager(): assert mc == "meshcore" assert manager.calls == [("sync_contacts", {"pause_polling": True})] + + +@pytest.mark.asyncio +async def test_lifecycle_passthrough_methods_delegate_to_current_manager(): + manager = _Manager(meshcore="meshcore", is_connected=True) + manager.start_connection_monitor = AsyncMock() + manager.stop_connection_monitor = AsyncMock() + manager.disconnect = AsyncMock() + runtime = RadioRuntime(manager) + + await runtime.start_connection_monitor() + await runtime.stop_connection_monitor() + await runtime.disconnect() + + manager.start_connection_monitor.assert_awaited_once() + manager.stop_connection_monitor.assert_awaited_once() + manager.disconnect.assert_awaited_once()