import { useState, useEffect, useMemo } from 'react'; import type { AppSettings, AppSettingsUpdate, HealthStatus, RadioConfig, RadioConfigUpdate, } from '../types'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Button } from './ui/button'; import { Separator } from './ui/separator'; import { toast } from './ui/sonner'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; // Radio presets for common configurations interface RadioPreset { name: string; freq: number; bw: number; sf: number; cr: number; } const RADIO_PRESETS: RadioPreset[] = [ { name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, { name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 }, { name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 }, { name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 }, { name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 }, { name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 }, { name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 }, { name: 'EU/UK/Switzerland Long Range', freq: 869.525, bw: 250, sf: 11, cr: 5 }, { name: 'EU/UK/Switzerland Medium Range', freq: 869.525, bw: 250, sf: 10, cr: 5 }, { name: 'EU/UK/Switzerland Narrow', freq: 869.618, bw: 62.5, sf: 8, cr: 8 }, { name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 }, { name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 }, { name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 }, { name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 }, { name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 }, ]; interface SettingsModalProps { open: boolean; config: RadioConfig | null; health: HealthStatus | null; appSettings: AppSettings | null; onClose: () => void; onSave: (update: RadioConfigUpdate) => Promise; onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onSetPrivateKey: (key: string) => Promise; onReboot: () => Promise; onAdvertise: () => Promise; onHealthRefresh: () => Promise; onRefreshAppSettings: () => Promise; } export function SettingsModal({ open, config, health, appSettings, onClose, onSave, onSaveAppSettings, onSetPrivateKey, onReboot, onAdvertise, onHealthRefresh, onRefreshAppSettings, }: SettingsModalProps) { // Tab state type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise'; const [activeTab, setActiveTab] = useState('radio'); // Radio config state const [name, setName] = useState(''); const [lat, setLat] = useState(''); const [lon, setLon] = useState(''); const [txPower, setTxPower] = useState(''); const [freq, setFreq] = useState(''); const [bw, setBw] = useState(''); const [sf, setSf] = useState(''); const [cr, setCr] = useState(''); const [privateKey, setPrivateKey] = useState(''); const [maxRadioContacts, setMaxRadioContacts] = useState(''); // Loading states const [loading, setLoading] = useState(false); const [rebooting, setRebooting] = useState(false); const [advertising, setAdvertising] = useState(false); const [gettingLocation, setGettingLocation] = useState(false); const [error, setError] = useState(''); // Database maintenance state const [retentionDays, setRetentionDays] = useState('14'); const [cleaning, setCleaning] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); useEffect(() => { if (config) { setName(config.name); setLat(String(config.lat)); setLon(String(config.lon)); setTxPower(String(config.tx_power)); setFreq(String(config.radio.freq)); setBw(String(config.radio.bw)); setSf(String(config.radio.sf)); setCr(String(config.radio.cr)); } }, [config]); useEffect(() => { if (appSettings) { setMaxRadioContacts(String(appSettings.max_radio_contacts)); setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); } }, [appSettings]); // Refresh settings from server when modal opens // This ensures UI reflects actual server state (prevents stale UI after checkbox toggle without save) useEffect(() => { if (open) { onRefreshAppSettings(); } }, [open, onRefreshAppSettings]); // Detect current preset from form values const currentPreset = useMemo(() => { const freqNum = parseFloat(freq); const bwNum = parseFloat(bw); const sfNum = parseInt(sf, 10); const crNum = parseInt(cr, 10); for (const preset of RADIO_PRESETS) { if ( preset.freq === freqNum && preset.bw === bwNum && preset.sf === sfNum && preset.cr === crNum ) { return preset.name; } } return 'custom'; }, [freq, bw, sf, cr]); const handlePresetChange = (presetName: string) => { if (presetName === 'custom') return; const preset = RADIO_PRESETS.find((p) => p.name === presetName); if (preset) { setFreq(String(preset.freq)); setBw(String(preset.bw)); setSf(String(preset.sf)); setCr(String(preset.cr)); } }; const handleGetLocation = () => { if (!navigator.geolocation) { toast.error('Geolocation not supported', { description: 'Your browser does not support geolocation', }); return; } setGettingLocation(true); navigator.geolocation.getCurrentPosition( (position) => { setLat(position.coords.latitude.toFixed(6)); setLon(position.coords.longitude.toFixed(6)); setGettingLocation(false); toast.success('Location updated'); }, (err) => { setGettingLocation(false); toast.error('Failed to get location', { description: err.message, }); }, { enableHighAccuracy: true, timeout: 10000 } ); }; const handleSaveRadioConfig = async () => { setError(''); setLoading(true); try { const update: RadioConfigUpdate = { lat: parseFloat(lat), lon: parseFloat(lon), tx_power: parseInt(txPower, 10), radio: { freq: parseFloat(freq), bw: parseFloat(bw), sf: parseInt(sf, 10), cr: parseInt(cr, 10), }, }; await onSave(update); toast.success('Radio config saved, rebooting...'); setLoading(false); setRebooting(true); await onReboot(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); setLoading(false); } finally { setRebooting(false); } }; const handleSaveIdentity = async () => { setError(''); setLoading(true); try { const update: RadioConfigUpdate = { name }; await onSave(update); toast.success('Identity saved'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setLoading(false); } }; const handleSaveSerial = async () => { setError(''); setLoading(true); try { const newMaxRadioContacts = parseInt(maxRadioContacts, 10); if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) { await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts }); } toast.success('Serial settings saved'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setLoading(false); } }; const handleSetPrivateKey = async () => { if (!privateKey.trim()) { setError('Private key is required'); return; } setError(''); setLoading(true); try { await onSetPrivateKey(privateKey.trim()); setPrivateKey(''); toast.success('Private key set, rebooting...'); setLoading(false); setRebooting(true); await onReboot(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to set private key'); setLoading(false); } finally { setRebooting(false); } }; const handleReboot = async () => { if ( !confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.') ) { return; } setError(''); setRebooting(true); try { await onReboot(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to reboot radio'); } finally { setRebooting(false); } }; const handleAdvertise = async () => { setAdvertising(true); try { await onAdvertise(); } finally { setAdvertising(false); } }; const handleCleanup = async () => { const days = parseInt(retentionDays, 10); if (isNaN(days) || days < 1) { toast.error('Invalid retention days', { description: 'Retention days must be at least 1', }); return; } setCleaning(true); try { const result = await api.runMaintenance(days); toast.success('Database cleanup complete', { description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`, }); await onHealthRefresh(); } catch (err) { console.error('Failed to run maintenance:', err); toast.error('Database cleanup failed', { description: err instanceof Error ? err.message : 'Unknown error', }); } finally { setCleaning(false); } }; const handleSaveDatabaseSettings = async () => { setLoading(true); setError(''); try { await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert }); toast.success('Database settings saved'); } catch (err) { console.error('Failed to save database settings:', err); setError(err instanceof Error ? err.message : 'Failed to save'); toast.error('Failed to save settings'); } finally { setLoading(false); } }; return ( !isOpen && onClose()}> Radio & Settings {activeTab === 'radio' && 'Configure radio frequency, power, and location settings'} {activeTab === 'identity' && 'Manage radio name, public key, and private key'} {activeTab === 'serial' && 'View serial port connection and configure contact sync'} {activeTab === 'database' && 'View database statistics and clean up old packets'} {activeTab === 'advertise' && 'Send a flood advertisement to announce your presence'} {!config ? (
Loading configuration...
) : ( setActiveTab(v as SettingsTab)} className="w-full" > Radio Identity Serial Database Advertise {/* Radio Config Tab */}
setFreq(e.target.value)} />
setBw(e.target.value)} />
setSf(e.target.value)} />
setCr(e.target.value)} />
setTxPower(e.target.value)} />
setLat(e.target.value)} />
setLon(e.target.value)} />
{error &&
{error}
}
{/* Identity Tab */}
setName(e.target.value)} />
setPrivateKey(e.target.value)} placeholder="64-character hex private key" />
{error &&
{error}
}
{/* Serial Tab */}
{health?.serial_port ? (
{health.serial_port}
) : (
Not connected
)}
setMaxRadioContacts(e.target.value)} />

Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)

{error &&
{error}
} {/* Database Tab */}
Database size {health?.database_size_mb ?? '?'} MB
{health?.oldest_undecrypted_timestamp ? (
Oldest undecrypted packet {formatTime(health.oldest_undecrypted_timestamp)} ( {Math.floor( (Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400 )}{' '} days old)
) : (
Oldest undecrypted packet None
)}

Delete undecrypted packets older than the specified days. This helps manage storage for packets that couldn't be decrypted (unknown channel keys).

setRetentionDays(e.target.value)} className="w-24" />

When enabled, the server will automatically try to decrypt stored DM packets when a new contact sends an advertisement. This may cause brief delays on large packet backlogs.

{error &&
{error}
}
{/* Advertise Tab */}

Send a flood advertisement to announce your presence on the mesh network.

{!health?.radio_connected && (

Radio not connected

)}
)}
); }