From 4e0b6a49b05450d633c75bcffa045a524e5b676e Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 11 Mar 2026 17:17:03 -0700 Subject: [PATCH] Add ability to pause radio connection (closes #51) --- app/radio.py | 24 +++++ app/routers/health.py | 20 ++++ app/routers/radio.py | 26 +++++- app/services/radio_lifecycle.py | 24 ++++- app/services/radio_runtime.py | 6 +- frontend/src/App.tsx | 4 + frontend/src/api.ts | 7 ++ frontend/src/components/SettingsModal.tsx | 6 ++ frontend/src/components/StatusBar.tsx | 29 ++++-- .../settings/SettingsRadioSection.tsx | 91 ++++++++++++++++--- frontend/src/hooks/useRadioControl.ts | 17 ++++ frontend/src/hooks/useRealtimeAppState.ts | 17 +++- frontend/src/test/settingsModal.test.tsx | 21 +++++ frontend/src/test/statusBar.test.tsx | 13 +++ frontend/src/types.ts | 1 + tests/test_health_mqtt_status.py | 39 ++++++++ tests/test_radio.py | 41 +++++++++ tests/test_radio_router.py | 19 ++++ 18 files changed, 371 insertions(+), 34 deletions(-) diff --git a/app/radio.py b/app/radio.py index 7ede1f4..0a705fa 100644 --- a/app/radio.py +++ b/app/radio.py @@ -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: diff --git a/app/routers/health.py b/app/routers/health.py index e757e3f..0fa6fc0 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -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, diff --git a/app/routers/radio.py b/app/routers/radio.py index 4e128a4..107be52 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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( diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index 4709cc8..110135c 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -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, diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index 177dc72..a5649c5 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b93e9f7..9981ce7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -73,6 +73,8 @@ export function App() { handleSaveConfig, handleSetPrivateKey, handleReboot, + handleDisconnect, + handleReconnect, handleAdvertise, handleHealthRefresh, } = useRadioControl(); @@ -338,6 +340,8 @@ export function App() { onSaveAppSettings: handleSaveAppSettings, onSetPrivateKey: handleSetPrivateKey, onReboot: handleReboot, + onDisconnect: handleDisconnect, + onReconnect: handleReconnect, onAdvertise: handleAdvertise, onHealthRefresh: handleHealthRefresh, onRefreshAppSettings: fetchAppSettings, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 645711b..8b1a678 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -101,6 +101,13 @@ export const api = { fetchJson<{ status: string; message: string }>('/radio/reboot', { method: 'POST', }), + disconnectRadio: () => + fetchJson<{ status: string; message: string; connected: boolean; paused: boolean }>( + '/radio/disconnect', + { + method: 'POST', + } + ), reconnectRadio: () => fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', { method: 'POST', diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e3a2a78..22e2d6d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -31,6 +31,8 @@ interface SettingsModalBaseProps { onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onSetPrivateKey: (key: string) => Promise; onReboot: () => Promise; + onDisconnect: () => Promise; + onReconnect: () => Promise; onAdvertise: () => Promise; onHealthRefresh: () => Promise; onRefreshAppSettings: () => Promise; @@ -59,6 +61,8 @@ export function SettingsModal(props: SettingsModalProps) { onSaveAppSettings, onSetPrivateKey, onReboot, + onDisconnect, + onReconnect, onAdvertise, onHealthRefresh, onRefreshAppSettings, @@ -182,6 +186,8 @@ export function SettingsModal(props: SettingsModalProps) { onSaveAppSettings={onSaveAppSettings} onSetPrivateKey={onSetPrivateKey} onReboot={onReboot} + onDisconnect={onDisconnect} + onReconnect={onReconnect} onAdvertise={onAdvertise} onClose={onClose} className={sectionContentClass} diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index ec3fd1f..58276b5 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -22,13 +22,24 @@ export function StatusBar({ onSettingsClick, onMenuClick, }: StatusBarProps) { + const radioState = + health?.radio_state ?? + (health?.radio_initializing + ? 'initializing' + : health?.radio_connected + ? 'connected' + : 'disconnected'); const connected = health?.radio_connected ?? false; - const initializing = health?.radio_initializing ?? false; - const statusLabel = initializing - ? 'Radio Initializing' - : connected - ? 'Radio OK' - : 'Radio Disconnected'; + const statusLabel = + radioState === 'paused' + ? 'Radio Paused' + : radioState === 'connecting' + ? 'Radio Connecting' + : radioState === 'initializing' + ? 'Radio Initializing' + : connected + ? 'Radio OK' + : 'Radio Disconnected'; const [reconnecting, setReconnecting] = useState(false); const [currentTheme, setCurrentTheme] = useState(getSavedTheme); @@ -97,7 +108,7 @@ export function StatusBar({
)} - {!connected && !initializing && ( + {(radioState === 'disconnected' || radioState === 'paused') && ( )} +

+ Disconnect pauses automatic reconnect attempts so another device can use the radio. +

{/* Radio Name */} diff --git a/frontend/src/hooks/useRadioControl.ts b/frontend/src/hooks/useRadioControl.ts index 4edfe7f..3326133 100644 --- a/frontend/src/hooks/useRadioControl.ts +++ b/frontend/src/hooks/useRadioControl.ts @@ -69,6 +69,21 @@ export function useRadioControl() { pollUntilReconnected(); }, [fetchConfig]); + const handleDisconnect = useCallback(async () => { + await api.disconnectRadio(); + const pausedHealth = await api.getHealth(); + setHealth(pausedHealth); + }, []); + + const handleReconnect = useCallback(async () => { + await api.reconnectRadio(); + const refreshedHealth = await api.getHealth(); + setHealth(refreshedHealth); + if (refreshedHealth.radio_connected) { + await fetchConfig(); + } + }, [fetchConfig]); + const handleAdvertise = useCallback(async () => { try { await api.sendAdvertisement(); @@ -100,6 +115,8 @@ export function useRadioControl() { handleSaveConfig, handleSetPrivateKey, handleReboot, + handleDisconnect, + handleReconnect, handleAdvertise, handleHealthRefresh, }; diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index eb61835..a4a6df1 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -128,6 +128,13 @@ export function useRealtimeAppState({ const prev = prevHealthRef.current; prevHealthRef.current = data; setHealth(data); + const nextRadioState = + data.radio_state ?? + (data.radio_initializing + ? 'initializing' + : data.radio_connected + ? 'connected' + : 'disconnected'); const initializationCompleted = prev !== null && prev.radio_connected && @@ -144,9 +151,13 @@ export function useRealtimeAppState({ }); fetchConfig(); } else { - toast.error('Radio disconnected', { - description: 'Check radio connection and power', - }); + if (nextRadioState === 'paused') { + toast.success('Radio connection paused'); + } else { + toast.error('Radio disconnected', { + description: 'Check radio connection and power', + }); + } } } diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 5d5d4fd..2935e8e 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -68,6 +68,8 @@ function renderModal(overrides?: { onClose?: () => void; onSetPrivateKey?: (key: string) => Promise; onReboot?: () => Promise; + onDisconnect?: () => Promise; + onReconnect?: () => Promise; open?: boolean; pageMode?: boolean; externalSidebarNav?: boolean; @@ -82,6 +84,8 @@ function renderModal(overrides?: { const onClose = overrides?.onClose ?? vi.fn(); const onSetPrivateKey = overrides?.onSetPrivateKey ?? vi.fn(async () => {}); const onReboot = overrides?.onReboot ?? vi.fn(async () => {}); + const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {}); + const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {}); const commonProps = { open: overrides?.open ?? true, @@ -94,6 +98,8 @@ function renderModal(overrides?: { onSaveAppSettings, onSetPrivateKey, onReboot, + onDisconnect, + onReconnect, onAdvertise: vi.fn(async () => {}), onHealthRefresh: vi.fn(async () => {}), onRefreshAppSettings, @@ -116,6 +122,8 @@ function renderModal(overrides?: { onClose, onSetPrivateKey, onReboot, + onDisconnect, + onReconnect, view, }; } @@ -186,6 +194,15 @@ describe('SettingsModal', () => { expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument(); }); + it('shows reconnect action when radio connection is paused', () => { + renderModal({ + health: { ...baseHealth, radio_state: 'paused' }, + }); + openRadioSection(); + + expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument(); + }); + it('saves changed max contacts value through onSaveAppSettings', async () => { const { onSaveAppSettings } = renderModal(); openRadioSection(); @@ -309,6 +326,8 @@ describe('SettingsModal', () => { onSaveAppSettings={onSaveAppSettings} onSetPrivateKey={vi.fn(async () => {})} onReboot={vi.fn(async () => {})} + onDisconnect={vi.fn(async () => {})} + onReconnect={vi.fn(async () => {})} onAdvertise={vi.fn(async () => {})} onHealthRefresh={vi.fn(async () => {})} onRefreshAppSettings={vi.fn(async () => {})} @@ -330,6 +349,8 @@ describe('SettingsModal', () => { onSave, onSetPrivateKey, onReboot, + onDisconnect: vi.fn(async () => {}), + onReconnect: vi.fn(async () => {}), }); openRadioSection(); diff --git a/frontend/src/test/statusBar.test.tsx b/frontend/src/test/statusBar.test.tsx index c664af6..21127a5 100644 --- a/frontend/src/test/statusBar.test.tsx +++ b/frontend/src/test/statusBar.test.tsx @@ -48,6 +48,19 @@ describe('StatusBar', () => { expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument(); }); + it('shows Radio Paused and a Connect action when reconnect attempts are paused', () => { + render( + + ); + + expect(screen.getByRole('status', { name: 'Radio Paused' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + }); + it('toggles between classic and light themes from the shortcut button', () => { localStorage.setItem('remoteterm-theme', 'cyberpunk'); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5d47ece..c990df9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -36,6 +36,7 @@ export interface HealthStatus { status: string; radio_connected: boolean; radio_initializing: boolean; + radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused'; connection_info: string | null; database_size_mb: number; oldest_undecrypted_timestamp: number | null; diff --git a/tests/test_health_mqtt_status.py b/tests/test_health_mqtt_status.py index 96feb15..c85225d 100644 --- a/tests/test_health_mqtt_status.py +++ b/tests/test_health_mqtt_status.py @@ -56,6 +56,7 @@ class TestHealthFanoutStatus: assert data["status"] == "ok" assert data["radio_connected"] is True assert data["radio_initializing"] is False + assert data["radio_state"] == "connected" assert data["connection_info"] == "Serial: /dev/ttyUSB0" @pytest.mark.asyncio @@ -69,6 +70,7 @@ class TestHealthFanoutStatus: assert data["status"] == "degraded" assert data["radio_connected"] is False assert data["radio_initializing"] is False + assert data["radio_state"] == "disconnected" assert data["connection_info"] is None @pytest.mark.asyncio @@ -87,3 +89,40 @@ class TestHealthFanoutStatus: assert data["status"] == "degraded" assert data["radio_connected"] is True assert data["radio_initializing"] is True + assert data["radio_state"] == "initializing" + + @pytest.mark.asyncio + async def test_health_state_paused_when_operator_disabled_connection(self, test_db): + """Health reports paused when the operator has disabled reconnect attempts.""" + with ( + patch( + "app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None + ), + patch("app.routers.health.radio_manager") as mock_rm, + ): + mock_rm.is_setup_in_progress = False + mock_rm.is_setup_complete = False + mock_rm.connection_desired = False + mock_rm.is_reconnecting = False + data = await build_health_data(False, "BLE: AA:BB:CC:DD:EE:FF") + + assert data["radio_state"] == "paused" + assert data["radio_connected"] is False + + @pytest.mark.asyncio + async def test_health_state_connecting_while_reconnect_in_progress(self, test_db): + """Health reports connecting while retries are active but transport is not up yet.""" + with ( + patch( + "app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None + ), + patch("app.routers.health.radio_manager") as mock_rm, + ): + mock_rm.is_setup_in_progress = False + mock_rm.is_setup_complete = False + mock_rm.connection_desired = True + mock_rm.is_reconnecting = True + data = await build_health_data(False, None) + + assert data["radio_state"] == "connecting" + assert data["radio_connected"] is False diff --git a/tests/test_radio.py b/tests/test_radio.py index 70cdac5..8554102 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -300,6 +300,29 @@ class TestConnectionMonitor: rm.post_connect_setup.assert_not_called() + @pytest.mark.asyncio + async def test_monitor_does_not_reconnect_when_connection_is_paused(self): + """Operator-paused state suppresses reconnect attempts.""" + from app.radio import RadioManager + + rm = RadioManager() + rm._connection_desired = False + rm.reconnect = AsyncMock() + rm.post_connect_setup = AsyncMock() + + async def _sleep(_seconds: float): + raise asyncio.CancelledError() + + with patch("app.radio.asyncio.sleep", side_effect=_sleep): + await rm.start_connection_monitor() + try: + await rm._reconnect_task + finally: + await rm.stop_connection_monitor() + + rm.reconnect.assert_not_called() + rm.post_connect_setup.assert_not_called() + class TestReconnectLock: """Tests for reconnect() lock serialization — no duplicate reconnections.""" @@ -408,6 +431,24 @@ class TestReconnectLock: assert result2 is True assert attempt == 2 + @pytest.mark.asyncio + async def test_reconnect_returns_false_when_connection_is_paused(self): + """Reconnect should no-op when the operator has paused connection attempts.""" + from app.radio import RadioManager + + rm = RadioManager() + rm._connection_desired = False + rm.connect = AsyncMock() + + with ( + patch("app.websocket.broadcast_health"), + patch("app.websocket.broadcast_error"), + ): + result = await rm.reconnect(broadcast_on_success=False) + + assert result is False + rm.connect.assert_not_called() + class TestSerialDeviceProbe: """Tests for test_serial_device() — verifies cleanup on all exit paths.""" diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 9fbbc55..a795a9a 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -15,6 +15,7 @@ from app.routers.radio import ( RadioConfigResponse, RadioConfigUpdate, RadioSettings, + disconnect_radio, get_radio_config, reboot_radio, reconnect_radio, @@ -394,3 +395,21 @@ class TestRebootAndReconnect: await reconnect_radio() assert exc.value.status_code == 503 + + @pytest.mark.asyncio + async def test_disconnect_pauses_connection_attempts_and_broadcasts_health(self): + mock_rm = MagicMock() + mock_rm.pause_connection = AsyncMock() + mock_rm.connection_info = "BLE: AA:BB:CC:DD:EE:FF" + + with ( + patch("app.routers.radio.radio_manager", _runtime(mock_rm)), + patch("app.routers.radio.broadcast_health") as mock_broadcast, + ): + result = await disconnect_radio() + + assert result["status"] == "ok" + assert result["connected"] is False + assert result["paused"] is True + mock_rm.pause_connection.assert_awaited_once() + mock_broadcast.assert_called_once_with(False, "BLE: AA:BB:CC:DD:EE:FF")