Add ability to pause radio connection (closes #51)

This commit is contained in:
Jack Kingsman
2026-03-11 17:17:03 -07:00
parent e993009782
commit 4e0b6a49b0
18 changed files with 371 additions and 34 deletions
+4
View File
@@ -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,
+7
View File
@@ -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}
+20 -9
View File
@@ -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 */}
+17
View File
@@ -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,
};
+14 -3
View File
@@ -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',
});
}
}
}
+21
View File
@@ -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();
+13
View File
@@ -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');
+1
View File
@@ -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;