mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add ability to pause radio connection (closes #51)
This commit is contained in:
24
app/radio.py
24
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -31,6 +31,8 @@ interface SettingsModalBaseProps {
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onDisconnect: () => Promise<void>;
|
||||
onReconnect: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
onRefreshAppSettings: () => Promise<void>;
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors',
|
||||
initializing
|
||||
radioState === 'initializing' || radioState === 'connecting'
|
||||
? 'bg-warning'
|
||||
: connected
|
||||
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
|
||||
@@ -128,13 +139,13 @@ export function StatusBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connected && !initializing && (
|
||||
{(radioState === 'disconnected' || radioState === 'paused') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
className="px-3 py-1 bg-warning/10 border border-warning/20 text-warning rounded-md text-xs cursor-pointer hover:bg-warning/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
|
||||
{reconnecting ? 'Reconnecting...' : radioState === 'paused' ? 'Connect' : 'Reconnect'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,8 @@ export function SettingsRadioSection({
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
onAdvertise,
|
||||
onClose,
|
||||
className,
|
||||
@@ -36,6 +38,8 @@ export function SettingsRadioSection({
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onDisconnect: () => Promise<void>;
|
||||
onReconnect: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
@@ -70,6 +74,7 @@ export function SettingsRadioSection({
|
||||
|
||||
// Advertise state
|
||||
const [advertising, setAdvertising] = useState(false);
|
||||
const [connectionBusy, setConnectionBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(config.name);
|
||||
@@ -285,24 +290,82 @@ export function SettingsRadioSection({
|
||||
}
|
||||
};
|
||||
|
||||
const radioState =
|
||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||
const connectionActionLabel =
|
||||
radioState === 'paused'
|
||||
? 'Reconnect'
|
||||
: radioState === 'connected' || radioState === 'initializing'
|
||||
? 'Disconnect'
|
||||
: 'Stop Trying';
|
||||
|
||||
const connectionStatusLabel =
|
||||
radioState === 'connected'
|
||||
? health?.connection_info || 'Connected'
|
||||
: radioState === 'initializing'
|
||||
? `Initializing ${health?.connection_info || 'radio'}`
|
||||
: radioState === 'connecting'
|
||||
? `Attempting to connect${health?.connection_info ? ` to ${health.connection_info}` : ''}`
|
||||
: radioState === 'paused'
|
||||
? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}`
|
||||
: 'Not connected';
|
||||
|
||||
const handleConnectionAction = async () => {
|
||||
setConnectionBusy(true);
|
||||
try {
|
||||
if (radioState === 'paused') {
|
||||
await onReconnect();
|
||||
toast.success('Reconnect requested');
|
||||
} else {
|
||||
await onDisconnect();
|
||||
toast.success('Radio connection paused');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to change radio connection state', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection and try again',
|
||||
});
|
||||
} finally {
|
||||
setConnectionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Connection display */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<Label>Connection</Label>
|
||||
{health?.connection_info ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-status-connected" />
|
||||
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
|
||||
{health.connection_info}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-status-disconnected" />
|
||||
<span>Not connected</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
radioState === 'connected'
|
||||
? 'bg-status-connected'
|
||||
: radioState === 'initializing' || radioState === 'connecting'
|
||||
? 'bg-warning'
|
||||
: 'bg-status-disconnected'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
radioState === 'paused' || radioState === 'disconnected'
|
||||
? 'text-muted-foreground'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{connectionStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleConnectionAction}
|
||||
disabled={connectionBusy}
|
||||
className="w-full"
|
||||
>
|
||||
{connectionBusy ? `${connectionActionLabel}...` : connectionActionLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disconnect pauses automatic reconnect attempts so another device can use the radio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Radio Name */}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ function renderModal(overrides?: {
|
||||
onClose?: () => void;
|
||||
onSetPrivateKey?: (key: string) => Promise<void>;
|
||||
onReboot?: () => Promise<void>;
|
||||
onDisconnect?: () => Promise<void>;
|
||||
onReconnect?: () => Promise<void>;
|
||||
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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
<StatusBar
|
||||
health={{ ...baseHealth, radio_state: 'paused' }}
|
||||
config={null}
|
||||
onSettingsClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user