mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
extract radio runtime seam
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
100
app/services/radio_runtime.py
Normal file
100
app/services/radio_runtime.py
Normal 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()
|
||||
75
tests/test_radio_runtime_service.py
Normal file
75
tests/test_radio_runtime_service.py
Normal 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})]
|
||||
Reference in New Issue
Block a user