extract radio runtime seam

This commit is contained in:
Jack Kingsman
2026-03-09 23:07:34 -07:00
parent 5e94b14b45
commit 81bdfe09fa
14 changed files with 210 additions and 22 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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__)

View File

@@ -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__)

View File

@@ -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))

View File

@@ -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__)

View File

@@ -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")

View File

@@ -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"])

View File

@@ -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

View File

@@ -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:

View File

@@ -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__)

View File

@@ -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()

View File

@@ -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})]