import { useState, useEffect, useMemo, lazy, Suspense } from 'react'; const BotCodeEditor = lazy(() => import('./BotCodeEditor').then((m) => ({ default: m.BotCodeEditor })) ); import type { AppSettings, AppSettingsUpdate, BotConfig, 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' | 'connectivity' | 'database' | 'bot'; 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); // Advertisement interval state const [advertInterval, setAdvertInterval] = useState('0'); // Bot state const DEFAULT_BOT_CODE = `def bot( sender_name: str | None, sender_key: str | None, message_text: str, is_dm: bool, channel_key: str | None, channel_name: str | None, sender_timestamp: int | None, path: str | None, ) -> str | list[str] | None: """ Process incoming messages and optionally return a reply. Args: sender_name: Display name of sender (may be None) sender_key: 64-char hex public key (None for channel msgs) message_text: The message content is_dm: True for direct messages, False for channel channel_key: 32-char hex key for channels, None for DMs channel_name: Channel name with hash (e.g. "#bot"), None for DMs sender_timestamp: Sender's timestamp (unix seconds, may be None) path: Hex-encoded routing path (may be None) Returns: None for no reply, a string for a single reply, or a list of strings to send multiple messages in order """ # Example: Only respond in #bot channel to "!pling" command if channel_name == "#bot" and "!pling" in message_text.lower(): return "[BOT] Plong!" return None`; const [bots, setBots] = useState([]); const [expandedBotId, setExpandedBotId] = useState(null); const [editingNameId, setEditingNameId] = useState(null); const [editingNameValue, setEditingNameValue] = useState(''); 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); setAdvertInterval(String(appSettings.advert_interval)); setBots(appSettings.bots || []); } }, [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 { // Save radio name const update: RadioConfigUpdate = { name }; await onSave(update); // Save advert interval to app settings const newAdvertInterval = parseInt(advertInterval, 10); if (!isNaN(newAdvertInterval) && newAdvertInterval !== appSettings?.advert_interval) { await onSaveAppSettings({ advert_interval: newAdvertInterval }); } toast.success('Identity settings saved'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save'); } finally { setLoading(false); } }; const handleSaveConnectivity = 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('Connectivity 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); } }; const handleSaveBotSettings = async () => { setLoading(true); setError(''); try { await onSaveAppSettings({ bots }); toast.success('Bot settings saved'); } catch (err) { console.error('Failed to save bot settings:', err); const errorMsg = err instanceof Error ? err.message : 'Failed to save'; setError(errorMsg); toast.error(errorMsg); } finally { setLoading(false); } }; const handleAddBot = () => { const newBot: BotConfig = { id: crypto.randomUUID(), name: `Bot ${bots.length + 1}`, enabled: false, code: DEFAULT_BOT_CODE, }; setBots([...bots, newBot]); setExpandedBotId(newBot.id); }; const handleDeleteBot = (botId: string) => { const bot = bots.find((b) => b.id === botId); if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) { if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) { return; } } setBots(bots.filter((b) => b.id !== botId)); if (expandedBotId === botId) { setExpandedBotId(null); } }; const handleToggleBotEnabled = (botId: string) => { setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b))); }; const handleBotCodeChange = (botId: string, code: string) => { setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b))); }; const handleStartEditingName = (bot: BotConfig) => { setEditingNameId(bot.id); setEditingNameValue(bot.name); }; const handleFinishEditingName = () => { if (editingNameId && editingNameValue.trim()) { setBots( bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b)) ); } setEditingNameId(null); setEditingNameValue(''); }; const handleResetBotCode = (botId: string) => { setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b))); }; return ( !isOpen && onClose()}> Radio & Settings {activeTab === 'radio' && 'Configure radio frequency, power, and location settings'} {activeTab === 'identity' && 'Manage radio name, public key, private key, and advertising settings'} {activeTab === 'connectivity' && 'View connection status and configure contact sync'} {activeTab === 'database' && 'View database statistics and clean up old packets'} {activeTab === 'bot' && 'Configure automatic message bot with Python code'} {!config ? (
Loading configuration...
) : ( { setActiveTab(v as SettingsTab); setError(''); }} className="w-full" > Radio Identity Connectivity Database Bot {/* 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)} />
setAdvertInterval(e.target.value)} className="w-28" /> seconds (0 = off)

How often to automatically advertise presence. Set to 0 to disable. Recommended: 86400 (24 hours) or higher.

setPrivateKey(e.target.value)} placeholder="64-character hex private key" />

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

{!health?.radio_connected && (

Radio not connected

)}
{error &&
{error}
}
{/* Connectivity Tab */}
{health?.connection_info ? (
{health.connection_info}
) : (
Not connected
)}
setMaxRadioContacts(e.target.value)} />

Favorite contacts load first, then recent non-repeater contacts until this limit is reached (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}
}
{/* Bot Tab */}

Experimental: This is an alpha feature and introduces automated message sending to your radio; unexpected behavior may occur. Use with caution, and please report any bugs!

Security Warning: This feature executes arbitrary Python code on the server. Only run trusted code, and be cautious of arbitrary usage of message parameters.

Don't wreck the mesh! Bots process ALL messages, including their own. Be careful of creating infinite loops!

{bots.length === 0 ? (

No bots configured

) : (
{bots.map((bot) => (
{/* Bot header row */}
{ // Don't toggle if clicking on interactive elements if ((e.target as HTMLElement).closest('input, button')) return; setExpandedBotId(expandedBotId === bot.id ? null : bot.id); }} > {expandedBotId === bot.id ? '▼' : '▶'} {/* Bot name (click to edit) */} {editingNameId === bot.id ? ( setEditingNameValue(e.target.value)} onBlur={handleFinishEditingName} onKeyDown={(e) => { if (e.key === 'Enter') handleFinishEditingName(); if (e.key === 'Escape') { setEditingNameId(null); setEditingNameValue(''); } }} autoFocus className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]" onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); handleStartEditingName(bot); }} title="Click to rename" > {bot.name} )} {/* Enabled checkbox */} {/* Delete button */}
{/* Bot expanded content */} {expandedBotId === bot.id && (

Define a bot() function that receives message data and optionally returns a reply.

Loading editor...
} > handleBotCodeChange(bot.id, code)} id={`bot-code-${bot.id}`} />
)}
))}
)}

Available: Standard Python libraries and any modules installed in the server environment.

Limits: 10 second timeout per bot.

Note: Bots respond to all messages, including your own. For channel messages, sender_key is None. Multiple enabled bots run serially, with a two-second delay between messages to prevent repeater collision.

{error &&
{error}
} )}
); }