From 81bdfe09fa6fde1ee416fee4cd2cbb4f0a7d6207 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:07:34 -0700 Subject: [PATCH] extract radio runtime seam --- AGENTS.md | 7 +- app/AGENTS.md | 4 +- app/dependencies.py | 7 +- app/routers/channels.py | 2 +- app/routers/contacts.py | 2 +- app/routers/health.py | 4 +- app/routers/messages.py | 2 +- app/routers/radio.py | 21 ++++-- app/routers/read_state.py | 2 +- app/routers/repeaters.py | 2 +- app/routers/settings.py | 2 +- app/routers/ws.py | 2 +- app/services/radio_runtime.py | 100 ++++++++++++++++++++++++++++ tests/test_radio_runtime_service.py | 75 +++++++++++++++++++++ 14 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 app/services/radio_runtime.py create mode 100644 tests/test_radio_runtime_service.py diff --git a/AGENTS.md b/AGENTS.md index 7247ef5..1b02d8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,8 +48,9 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific │ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │ │ ↓ │ ┌───────────┐ │ │ ┌──────────────────────────┐ └──────────────→ │ WebSocket │ │ -│ │ RadioManager + lifecycle │ │ Manager │ │ -│ │ / event adapters │ └───────────┘ │ +│ │ Radio runtime seam + │ │ Manager │ │ +│ │ RadioManager lifecycle │ └───────────┘ │ +│ │ / event adapters │ │ │ └──────────────────────────┘ │ └───────────────────────────┼──────────────────────────────────────┘ │ Serial / TCP / BLE @@ -163,7 +164,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ ├── AGENTS.md # Backend documentation │ ├── main.py # App entry, lifespan │ ├── routers/ # API endpoints -│ ├── services/ # Shared backend orchestration/domain services +│ ├── services/ # Shared backend orchestration/domain services, including radio_runtime access seam │ ├── packet_processor.py # Raw packet pipeline, dedup, path handling │ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout) │ ├── event_handlers.py # Radio events diff --git a/app/AGENTS.md b/app/AGENTS.md index 6455aae..435ea22 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -27,7 +27,8 @@ app/ │ ├── dm_ack_tracker.py # Pending DM ACK state │ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring │ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers -│ └── radio_commands.py # Radio config/private-key command workflows +│ ├── radio_commands.py # Radio config/private-key command workflows +│ └── radio_runtime.py # Router/dependency seam over the global RadioManager ├── radio.py # RadioManager transport/session state + lock management ├── radio_sync.py # Polling, sync, periodic advertisement loop ├── decoder.py # Packet parsing/decryption @@ -76,6 +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. - 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/dependencies.py b/app/dependencies.py index b6af89d..a0bbcd2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -2,7 +2,7 @@ from fastapi import HTTPException -from app.radio import radio_manager +from app.services.radio_runtime import radio_runtime as radio_manager def require_connected(): @@ -12,6 +12,7 @@ def require_connected(): """ if getattr(radio_manager, "is_setup_in_progress", False) is True: raise HTTPException(status_code=503, detail="Radio is initializing") - if not radio_manager.is_connected or radio_manager.meshcore is None: + mc = getattr(radio_manager, "meshcore", None) + if not getattr(radio_manager, "is_connected", False) or mc is None: raise HTTPException(status_code=503, detail="Radio not connected") - return radio_manager.meshcore + return mc diff --git a/app/routers/channels.py b/app/routers/channels.py index f9ea589..807318a 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -7,10 +7,10 @@ from pydantic import BaseModel, Field from app.dependencies import require_connected from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender -from app.radio import radio_manager from app.radio_sync import upsert_channel_from_radio_slot from app.region_scope import normalize_region_scope from app.repository import ChannelRepository, MessageRepository +from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_event logger = logging.getLogger(__name__) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index ad1de33..c62f16f 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -18,7 +18,6 @@ from app.models import ( ) from app.packet_processor import start_historical_dm_decryption from app.path_utils import parse_explicit_hop_route -from app.radio import radio_manager from app.repository import ( AmbiguousPublicKeyPrefixError, ContactAdvertPathRepository, @@ -27,6 +26,7 @@ from app.repository import ( MessageRepository, ) from app.services.contact_reconciliation import reconcile_contact_messages +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) diff --git a/app/routers/health.py b/app/routers/health.py index 30901a7..e757e3f 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -5,8 +5,8 @@ from fastapi import APIRouter from pydantic import BaseModel from app.config import settings -from app.radio import radio_manager from app.repository import RawPacketRepository +from app.services.radio_runtime import radio_runtime as radio_manager router = APIRouter(tags=["health"]) @@ -53,6 +53,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected) if not isinstance(setup_complete, bool): setup_complete = radio_connected + if not radio_connected: + setup_complete = False radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete)) diff --git a/app/routers/messages.py b/app/routers/messages.py index 588d064..11ea1cf 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -11,13 +11,13 @@ from app.models import ( SendChannelMessageRequest, SendDirectMessageRequest, ) -from app.radio import radio_manager from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository from app.services.message_send import ( resend_channel_message_record, send_channel_message_to_channel, send_direct_message_to_contact, ) +from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_error, broadcast_event logger = logging.getLogger(__name__) diff --git a/app/routers/radio.py b/app/routers/radio.py index c58ea9c..0914cf0 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.dependencies import require_connected -from app.radio import radio_manager from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time from app.services.radio_commands import ( @@ -14,11 +13,20 @@ from app.services.radio_commands import ( apply_radio_config_update, import_private_key_and_refresh_keystore, ) +from app.services.radio_lifecycle import prepare_connected_radio, reconnect_and_prepare_radio +from app.services.radio_runtime import RadioRuntime +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) +def _unwrap_radio_manager(): + if isinstance(radio_manager, RadioRuntime): + return radio_manager.manager + return radio_manager + + class RadioSettings(BaseModel): freq: float = Field(description="Frequency in MHz") bw: float = Field(description="Bandwidth in kHz") @@ -160,8 +168,6 @@ async def send_advertisement() -> dict: async def _attempt_reconnect() -> dict: """Shared reconnection logic for reboot and reconnect endpoints.""" - from app.services.radio_lifecycle import reconnect_and_prepare_radio - if radio_manager.is_reconnecting: return { "status": "pending", @@ -171,7 +177,7 @@ async def _attempt_reconnect() -> dict: try: success = await reconnect_and_prepare_radio( - radio_manager, + _unwrap_radio_manager(), broadcast_on_success=True, ) except Exception as e: @@ -217,15 +223,16 @@ async def reconnect_radio() -> dict: if no specific port is configured. Useful when the radio has been disconnected or power-cycled. """ - from app.services.radio_lifecycle import prepare_connected_radio - if radio_manager.is_connected: if radio_manager.is_setup_complete: return {"status": "ok", "message": "Already connected", "connected": True} logger.info("Radio connected but setup incomplete, retrying setup") try: - await prepare_connected_radio(radio_manager, broadcast_on_success=True) + await prepare_connected_radio( + _unwrap_radio_manager(), + broadcast_on_success=True, + ) return {"status": "ok", "message": "Setup completed", "connected": True} except Exception as e: logger.exception("Post-connect setup failed") diff --git a/app/routers/read_state.py b/app/routers/read_state.py index 28af10c..c13f8a4 100644 --- a/app/routers/read_state.py +++ b/app/routers/read_state.py @@ -6,13 +6,13 @@ import time from fastapi import APIRouter from app.models import UnreadCounts -from app.radio import radio_manager from app.repository import ( AppSettingsRepository, ChannelRepository, ContactRepository, MessageRepository, ) +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/read-state", tags=["read-state"]) diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index bbb43ae..666cd03 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -25,9 +25,9 @@ from app.models import ( RepeaterRadioSettingsResponse, RepeaterStatusResponse, ) -from app.radio import radio_manager from app.repository import ContactRepository from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 +from app.services.radio_runtime import radio_runtime as radio_manager if TYPE_CHECKING: from meshcore.events import Event diff --git a/app/routers/settings.py b/app/routers/settings.py index a5e053c..2aed40a 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -132,7 +132,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: # Apply flood scope to radio immediately if changed if flood_scope_changed: - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager if radio_manager.is_connected: try: diff --git a/app/routers/ws.py b/app/routers/ws.py index c9eafdb..2494098 100644 --- a/app/routers/ws.py +++ b/app/routers/ws.py @@ -4,8 +4,8 @@ import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from app.radio import radio_manager from app.routers.health import build_health_data +from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import ws_manager logger = logging.getLogger(__name__) diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py new file mode 100644 index 0000000..7061753 --- /dev/null +++ b/app/services/radio_runtime.py @@ -0,0 +1,100 @@ +"""Shared access seam over the global RadioManager instance. + +This module deliberately keeps behavior thin and forwarding-only. The goal is +to reduce direct `app.radio.radio_manager` imports across routers and helpers +without changing radio lifecycle, lock, or connection semantics. +""" + +from collections.abc import Callable +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import HTTPException + +import app.radio as radio_module + + +class RadioRuntime: + """Thin wrapper around the process-global RadioManager.""" + + def __init__(self, manager_or_getter=None): + if manager_or_getter is None: + self._manager_getter: Callable[[], Any] = lambda: radio_module.radio_manager + elif callable(manager_or_getter): + self._manager_getter = manager_or_getter + else: + self._manager_getter = lambda: manager_or_getter + + @property + def manager(self) -> Any: + return self._manager_getter() + + @property + def meshcore(self): + return self.manager.meshcore + + @property + def connection_info(self) -> str | None: + return self.manager.connection_info + + @property + def is_connected(self) -> bool: + return self.manager.is_connected + + @property + def is_reconnecting(self) -> bool: + return self.manager.is_reconnecting + + @property + def is_setup_in_progress(self) -> bool: + return self.manager.is_setup_in_progress + + @property + def is_setup_complete(self) -> bool: + return self.manager.is_setup_complete + + @property + def path_hash_mode(self) -> int: + return self.manager.path_hash_mode + + @path_hash_mode.setter + def path_hash_mode(self, mode: int) -> None: + self.manager.path_hash_mode = mode + + @property + def path_hash_mode_supported(self) -> bool: + return self.manager.path_hash_mode_supported + + @path_hash_mode_supported.setter + def path_hash_mode_supported(self, supported: bool) -> None: + self.manager.path_hash_mode_supported = supported + + def require_connected(self): + """Return MeshCore when available, mirroring existing HTTP semantics.""" + if self.is_setup_in_progress: + raise HTTPException(status_code=503, detail="Radio is initializing") + mc = self.meshcore + if not self.is_connected or mc is None: + raise HTTPException(status_code=503, detail="Radio not connected") + return mc + + @asynccontextmanager + async def radio_operation(self, name: str, **kwargs): + async with self.manager.radio_operation(name, **kwargs) as mc: + yield mc + + async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None: + from app.services.radio_lifecycle import prepare_connected_radio + + 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 + + return await reconnect_and_prepare_radio( + self.manager, + broadcast_on_success=broadcast_on_success, + ) + + +radio_runtime = RadioRuntime() diff --git a/tests/test_radio_runtime_service.py b/tests/test_radio_runtime_service.py new file mode 100644 index 0000000..aa53082 --- /dev/null +++ b/tests/test_radio_runtime_service.py @@ -0,0 +1,75 @@ +from contextlib import asynccontextmanager + +import pytest +from fastapi import HTTPException + +from app.services.radio_runtime import RadioRuntime + + +class _Manager: + def __init__( + self, + *, + meshcore=None, + is_connected=False, + is_reconnecting=False, + is_setup_in_progress=False, + is_setup_complete=False, + connection_info=None, + path_hash_mode=0, + path_hash_mode_supported=False, + ): + self.meshcore = meshcore + self.is_connected = is_connected + self.is_reconnecting = is_reconnecting + self.is_setup_in_progress = is_setup_in_progress + self.is_setup_complete = is_setup_complete + self.connection_info = connection_info + self.path_hash_mode = path_hash_mode + self.path_hash_mode_supported = path_hash_mode_supported + self.calls: list[tuple[str, dict]] = [] + + @asynccontextmanager + async def radio_operation(self, name: str, **kwargs): + self.calls.append((name, kwargs)) + yield self.meshcore + + +def test_uses_latest_manager_from_getter(): + first = _Manager(meshcore="mc1", is_connected=True, connection_info="first") + second = _Manager(meshcore="mc2", is_connected=True, connection_info="second") + current = {"manager": first} + runtime = RadioRuntime(lambda: current["manager"]) + + assert runtime.connection_info == "first" + assert runtime.require_connected() == "mc1" + + current["manager"] = second + + assert runtime.connection_info == "second" + assert runtime.require_connected() == "mc2" + + +def test_require_connected_preserves_http_semantics(): + runtime = RadioRuntime( + _Manager(meshcore=None, is_connected=True, is_setup_in_progress=True), + ) + with pytest.raises(HTTPException, match="Radio is initializing") as exc: + runtime.require_connected() + assert exc.value.status_code == 503 + + runtime = RadioRuntime(_Manager(meshcore=None, is_connected=False, is_setup_in_progress=False)) + with pytest.raises(HTTPException, match="Radio not connected") as exc: + runtime.require_connected() + assert exc.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_radio_operation_delegates_to_current_manager(): + manager = _Manager(meshcore="meshcore", is_connected=True) + runtime = RadioRuntime(manager) + + async with runtime.radio_operation("sync_contacts", pause_polling=True) as mc: + assert mc == "meshcore" + + assert manager.calls == [("sync_contacts", {"pause_polling": True})]